From c78df3a6a613b5f9dff1840a862842910310ab3d Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 18:43:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0(=E7=99=BB=E5=BD=95/=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86/=E6=97=A5=E5=BF=97=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B)=20+=20=E8=A1=8C=E4=B8=BA=E6=97=A5=E5=BF=97=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=87=87=E9=9B=86(=E8=AF=B7=E6=B1=82=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6=20+=20log=5Fevent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/index.html | 12 + admin/package-lock.json | 1147 +++++++++++++++++++++++++++++ admin/package.json | 18 + admin/src/App.vue | 124 ++++ admin/src/api/admin.js | 46 ++ admin/src/components/UserForm.vue | 101 +++ admin/src/main.js | 5 + admin/src/style.css | 96 +++ admin/src/views/Login.vue | 53 ++ admin/src/views/Logs.vue | 102 +++ admin/src/views/Users.vue | 72 ++ admin/vite.config.js | 15 + sdk/app/auth.py | 35 + sdk/app/config.py | 8 + sdk/app/db.py | 29 + sdk/app/logwriter.py | 154 ++++ sdk/app/main.py | 39 +- sdk/app/routers/admin.py | 149 ++++ sdk/app/services/auth.py | 189 +++++ sdk/app/services/logview.py | 100 +++ 20 files changed, 2493 insertions(+), 1 deletion(-) create mode 100644 admin/index.html create mode 100644 admin/package-lock.json create mode 100644 admin/package.json create mode 100644 admin/src/App.vue create mode 100644 admin/src/api/admin.js create mode 100644 admin/src/components/UserForm.vue create mode 100644 admin/src/main.js create mode 100644 admin/src/style.css create mode 100644 admin/src/views/Login.vue create mode 100644 admin/src/views/Logs.vue create mode 100644 admin/src/views/Users.vue create mode 100644 admin/vite.config.js create mode 100644 sdk/app/auth.py create mode 100644 sdk/app/logwriter.py create mode 100644 sdk/app/routers/admin.py create mode 100644 sdk/app/services/auth.py create mode 100644 sdk/app/services/logview.py diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..1ddfebe --- /dev/null +++ b/admin/index.html @@ -0,0 +1,12 @@ + + + + + + 蜂群中枢 · PlantAI 管理后台 + + +
+ + + diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..3c61da4 --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,1147 @@ +{ + "name": "plantai-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plantai-admin", + "version": "0.1.0", + "dependencies": { + "vue": "3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "5.2.1", + "vite": "5.4.11" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz", + "integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..fdf173c --- /dev/null +++ b/admin/package.json @@ -0,0 +1,18 @@ +{ + "name": "plantai-admin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "3.5.13" + }, + "devDependencies": { + "vite": "5.4.11", + "@vitejs/plugin-vue": "5.2.1" + } +} diff --git a/admin/src/App.vue b/admin/src/App.vue new file mode 100644 index 0000000..8d703ce --- /dev/null +++ b/admin/src/App.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/admin/src/api/admin.js b/admin/src/api/admin.js new file mode 100644 index 0000000..1bbd40c --- /dev/null +++ b/admin/src/api/admin.js @@ -0,0 +1,46 @@ +const TOKEN_KEY = 'pa-admin-token'; +let token = localStorage.getItem(TOKEN_KEY) || ''; + +export function getToken() { return token; } +export function setToken(t) { + token = t || ''; + if (t) localStorage.setItem(TOKEN_KEY, t); + else localStorage.removeItem(TOKEN_KEY); +} + +async function request(path, { method = 'GET', body } = {}) { + const res = await fetch('/api/admin' + path, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(token ? { Authorization: 'Bearer ' + token } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { data = await res.json(); } catch { data = null; } + if (!res.ok) { + const err = new Error((data && data.error && data.error.message) || ('请求失败 HTTP ' + res.status)); + err.status = res.status; + err.code = data && data.error && data.error.code; + throw err; + } + return data; +} + +export const api = { + login: (username, password) => request('/login', { method: 'POST', body: { username, password } }), + logout: () => request('/logout', { method: 'POST' }), + me: () => request('/me'), + units: () => request('/units'), + users: () => request('/users'), + createUser: (body) => request('/users', { method: 'POST', body }), + updateUser: (id, body) => request('/users/' + id, { method: 'PATCH', body }), + deleteUser: (id) => request('/users/' + id, { method: 'DELETE' }), + logs: (params = {}) => { + const q = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') q.set(k, v); }); + const qs = q.toString(); + return request('/logs' + (qs ? '?' + qs : '')); + }, +}; diff --git a/admin/src/components/UserForm.vue b/admin/src/components/UserForm.vue new file mode 100644 index 0000000..efbd261 --- /dev/null +++ b/admin/src/components/UserForm.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/admin/src/main.js b/admin/src/main.js new file mode 100644 index 0000000..8dd6bc1 --- /dev/null +++ b/admin/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import './style.css'; + +createApp(App).mount('#app'); diff --git a/admin/src/style.css b/admin/src/style.css new file mode 100644 index 0000000..d955355 --- /dev/null +++ b/admin/src/style.css @@ -0,0 +1,96 @@ +:root { + --bg: #0c0e14; + --bg-2: #10131b; + --surface: #151924; + --surface-2: #1a1f2c; + --border: #232a38; + --border-2: #2e3648; + --text: #e9edf5; + --text-2: #9aa4b6; + --text-3: #5f6a7d; + --accent: #e8a33d; + --accent-ink: #14100a; + --accent-soft: rgba(232, 163, 61, 0.14); + --green: #3ecf8e; + --green-soft: rgba(62, 207, 142, 0.14); + --red: #ef6b6b; + --red-soft: rgba(239, 107, 107, 0.14); + --blue: #6ea8ff; + --radius: 10px; + --radius-sm: 7px; +} + +* { box-sizing: border-box; } +html, body, #app { margin: 0; height: 100%; } +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +.mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; } +button { font-family: inherit; cursor: pointer; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + padding: 9px 15px; font-size: 13.5px; border-radius: var(--radius-sm); + border: 1px solid transparent; transition: filter .15s; background: transparent; +} +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.btn-primary { background: var(--accent); color: var(--accent-ink); font-weight: 600; } +.btn-primary:hover { filter: brightness(1.06); } +.btn-ghost { border-color: var(--border-2); color: var(--text-2); } +.btn-ghost:hover { color: var(--text); border-color: var(--text-3); } +.btn-danger { background: var(--red-soft); color: var(--red); } +.btn-block { width: 100%; } + +.field { margin-bottom: 15px; } +.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 7px; } +.field input, .field select { + width: 100%; padding: 10px 12px; font-size: 14px; color: var(--text); + background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none; +} +.field input:focus, .field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + +.chip { + display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px; + border: 1px solid var(--border-2); color: var(--text-2); margin: 1.5px 3px 1.5px 0; +} +.chip.amber { border-color: transparent; background: var(--accent-soft); color: var(--accent); } +.badge { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; } +.badge.green { background: var(--green-soft); color: var(--green); } +.badge.red { background: var(--red-soft); color: var(--red); } +.badge.amber { background: var(--accent-soft); color: var(--accent); } +.badge.muted { background: var(--surface-2); color: var(--text-3); } + +.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); } +table { width: 100%; border-collapse: collapse; } +th { text-align: left; font-size: 11.5px; font-weight: 500; color: var(--text-3); padding: 10px 18px; border-bottom: 1px solid var(--border); } +td { padding: 13px 18px; border-bottom: 1px solid var(--border); font-size: 13.5px; } +tr:last-child td { border-bottom: none; } +tbody tr:hover { background: rgba(255,255,255,0.015); } +.cell-id { color: var(--text-2); } +.row-actions { display: flex; gap: 6px; } +.row-actions .act { color: var(--blue); cursor: pointer; font-size: 13px; padding: 3px 6px; border-radius: 5px; } +.row-actions .act:hover { background: rgba(110,168,255,0.1); } +.row-actions .act.danger { color: var(--red); } +.row-actions .act.danger:hover { background: var(--red-soft); } + +.role { font-weight: 600; } +.role.admin { color: var(--accent); } +.role.researcher { color: var(--blue); } +.role.guest { color: var(--text-2); } + +.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 50; } +.modal { width: 460px; max-width: calc(100vw - 32px); background: var(--surface); border: 1px solid var(--border-2); border-radius: 14px; } +.modal-head { padding: 18px 22px 0; font-size: 15px; font-weight: 600; } +.modal-body { padding: 18px 22px; } +.modal-foot { padding: 14px 22px 20px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--border); } +.checks { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 14px; margin-top: 8px; } +.check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); } +.check input { accent-color: var(--accent); width: 15px; height: 15px; } +.seg { display: flex; gap: 6px; } +.seg button { background: transparent; border: 1px solid var(--border-2); color: var(--text-2); border-radius: 7px; padding: 7px 14px; font-size: 13px; } +.seg button.on { background: var(--accent-soft); border-color: transparent; color: var(--accent); } diff --git a/admin/src/views/Login.vue b/admin/src/views/Login.vue new file mode 100644 index 0000000..6868b14 --- /dev/null +++ b/admin/src/views/Login.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/admin/src/views/Logs.vue b/admin/src/views/Logs.vue new file mode 100644 index 0000000..a43c757 --- /dev/null +++ b/admin/src/views/Logs.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/admin/src/views/Users.vue b/admin/src/views/Users.vue new file mode 100644 index 0000000..6abea8c --- /dev/null +++ b/admin/src/views/Users.vue @@ -0,0 +1,72 @@ + + + diff --git a/admin/vite.config.js b/admin/vite.config.js new file mode 100644 index 0000000..21ee8d4 --- /dev/null +++ b/admin/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + base: '/admin/', + plugins: [vue()], + server: { + host: '127.0.0.1', + port: 5181, + proxy: { + '/api': { target: 'http://127.0.0.1:8090', changeOrigin: false }, + }, + }, + build: { outDir: 'dist', target: 'es2020' }, +}); diff --git a/sdk/app/auth.py b/sdk/app/auth.py new file mode 100644 index 0000000..400a670 --- /dev/null +++ b/sdk/app/auth.py @@ -0,0 +1,35 @@ +"""auth.py —— 管理后台鉴权依赖 + 鉴权异常。""" +from __future__ import annotations + +from fastapi import Depends, Request + +from .db import Database, get_db +from .logwriter import log_event +from .services.auth import resolve_token + + +class AuthError(Exception): + def __init__(self, status: int, code: str, message: str) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +def _bearer(request: Request) -> str: + header = request.headers.get("Authorization", "") + return header[7:] if header.startswith("Bearer ") else "" + + +async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict: + """校验 Authorization: Bearer ,并要求角色为 admin;失败抛 AuthError 并留痕。""" + token = _bearer(request) + user = await resolve_token(db, token) if token else None + if user is None: + log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"}) + raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期") + if user["role"] != "admin": + log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"}) + raise AuthError(403, "FORBIDDEN", "非管理员账号") + request.state.user = user + return user diff --git a/sdk/app/config.py b/sdk/app/config.py index 8a07ef1..f841fe4 100644 --- a/sdk/app/config.py +++ b/sdk/app/config.py @@ -86,6 +86,14 @@ class Settings(BaseSettings): review_service_url: str = "http://127.0.0.1:8100" review_read_timeout: float = 360.0 # presubmit 上传+LLM 长调用读超时(秒) + # ---- 管理后台(admin console + 行为日志)---- + admin_seed_username: str = "admin" # 种子管理员用户名(启动时若无 admin 则创建) + admin_seed_password: str = "admin" # 种子管理员初始密码 + admin_token_ttl_seconds: int = 86400 # 管理员会话凭据有效期(秒) + admin_dist_dir: str = "admin/dist" # 管理后台前端构建产物(BFF 托管 /admin) + log_dir: str = "data/logs" # 行为日志目录(每日一个 JSONL 文件) + log_retention_days: int = 0 # 日志保留天数(0=不清理) + # ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)---- frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist diff --git a/sdk/app/db.py b/sdk/app/db.py index d194a8d..93327c1 100644 --- a/sdk/app/db.py +++ b/sdk/app/db.py @@ -125,6 +125,30 @@ _DOCGEN_MIGRATIONS = ( ) +# 管理后台/鉴权增量表(users + auth_tokens;行为日志走文件,不建表) +_ADMIN_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + authorizations TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); +""" + + def _now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -175,6 +199,11 @@ class Database: ) except Exception: logger.exception("docgen 表迁移失败(docgen 端点将不可用,其余不受影响)") + # 管理后台/鉴权增量表(users + auth_tokens):单独执行;失败只记日志 + try: + await self._conn.executescript(_ADMIN_SCHEMA) + except Exception: + logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)") await self._conn.commit() async def close(self) -> None: diff --git a/sdk/app/logwriter.py b/sdk/app/logwriter.py new file mode 100644 index 0000000..6e0607d --- /dev/null +++ b/sdk/app/logwriter.py @@ -0,0 +1,154 @@ +"""logwriter.py —— 行为日志异步文件写入器(JSONL,固定目录,每日一个文件)。 + +设计:请求/业务只入队(不落盘),后台单任务批量 append,避免日志拖慢接口; +文件按日期命名 audit-YYYY-MM-DD.log,跨天自动切文件。写失败仅记 stderr,绝不影响业务。 +""" +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_writer: "LogWriter | None" = None # 全局单例 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def _today() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +class LogWriter: + def __init__(self, log_dir: str, retention_days: int = 0) -> None: + self.log_dir = Path(log_dir) + self.retention_days = retention_days + self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=20000) + self._task: asyncio.Task | None = None + self._date = "" + self._fh = None + + async def start(self) -> None: + self.log_dir.mkdir(parents=True, exist_ok=True) + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._close_fh() + + def enqueue(self, entry: dict[str, Any]) -> None: + try: + self._queue.put_nowait(entry) + except asyncio.QueueFull: + logger.warning("行为日志队列已满,丢弃一条") + + def _close_fh(self) -> None: + if self._fh is not None: + try: + self._fh.close() + except Exception: + pass + self._fh = None + + def _open_for_date(self, date_str: str) -> None: + self._close_fh() + path = self.log_dir / f"audit-{date_str}.log" + self._fh = open(path, "a", encoding="utf-8") + self._date = date_str + self._cleanup() + + async def _run(self) -> None: + try: + while True: + batch: list[dict[str, Any]] = [] + try: + batch.append(await asyncio.wait_for(self._queue.get(), timeout=1.0)) + except asyncio.TimeoutError: + continue + while len(batch) < 200: + try: + batch.append(self._queue.get_nowait()) + except asyncio.QueueEmpty: + break + await asyncio.to_thread(self._write_sync, batch) + except asyncio.CancelledError: + raise + + def _write_sync(self, entries: list[dict[str, Any]]) -> None: + today = _today() + if self._date != today: + self._open_for_date(today) + for e in entries: + self._fh.write(json.dumps(e, ensure_ascii=False) + "\n") + self._fh.flush() + + def _cleanup(self) -> None: + if not self.retention_days: + return + try: + import time as _time + + cutoff = _time.time() - self.retention_days * 86400 + for p in self.log_dir.glob("audit-*.log"): + try: + if p.stat().st_mtime < cutoff: + p.unlink() + except OSError: + continue + except Exception: + logger.exception("日志清理失败") + + +def init_writer(log_dir: str, retention_days: int = 0) -> None: + global _writer + _writer = LogWriter(log_dir, retention_days) + + +async def start_writer() -> None: + if _writer is not None: + await _writer.start() + + +async def stop_writer() -> None: + global _writer + if _writer is not None: + await _writer.stop() + _writer = None + + +def log_event( + event: str, + *, + user_id: str | None = None, + method: str | None = None, + path: str | None = None, + status: int | None = None, + latency_ms: float | None = None, + detail: dict[str, Any] | None = None, +) -> None: + """通用业务埋点;writer 未初始化时静默 no-op。""" + if _writer is None: + return + entry = { + "ts": _now_iso(), + "user_id": user_id, + "event": event, + "method": method, + "path": path, + "status": status, + "latency_ms": latency_ms, + "detail": detail or {}, + } + _writer.enqueue(entry) diff --git a/sdk/app/main.py b/sdk/app/main.py index 1a5fdcd..51bd543 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +import time from contextlib import asynccontextmanager from pathlib import Path @@ -27,6 +28,10 @@ from .routers import bioflow_proxy from .routers import paper from .routers import private_docs from .routers import review_proxy +from .routers import admin +from . import logwriter +from .auth import AuthError +from .services.auth import seed_admin from .upstream import close_client logging.basicConfig(level=logging.INFO) @@ -49,6 +54,29 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # 行为日志请求中间件:自动采集所有 /api/* 请求(user_id 待鉴权上线后补) + @app.middleware("http") + async def _log_requests(request: Request, call_next): + start = time.monotonic() + response = await call_next(request) + if request.url.path.startswith("/api/"): + logwriter.log_event( + "http_request", + method=request.method, + path=request.url.path, + status=response.status_code, + latency_ms=round((time.monotonic() - start) * 1000, 1), + ) + return response + + # 鉴权异常 → 统一错误信封 + @app.exception_handler(AuthError) + async def _auth_error_handler(_: Request, exc: AuthError) -> JSONResponse: + return JSONResponse( + status_code=exc.status, + content={"ok": False, "error": {"code": exc.code, "message": exc.message, "degraded": False}}, + ) + app.include_router(health.router) app.include_router(sessions.router) app.include_router(qa.router) @@ -65,6 +93,7 @@ def create_app() -> FastAPI: app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关) if get_settings().review_enabled: app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关) + app.include_router(admin.router) # 管理后台(/api/admin/*) @app.exception_handler(RequestValidationError) async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse: @@ -79,10 +108,14 @@ def create_app() -> FastAPI: @asynccontextmanager async def _lifespan(_app: FastAPI): - await init_db() + _db = await init_db() + logwriter.init_writer(settings.log_dir, settings.log_retention_days) + await logwriter.start_writer() + await seed_admin(_db) try: yield finally: + await logwriter.stop_writer() await close_client() try: from .services.neo4j import close_neo4j # A3 提供;缺失则跳过 @@ -98,6 +131,10 @@ def create_app() -> FastAPI: # 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 sdk/, # 相对路径会解析成 sdk/ui/dist);sync.sh 排除 dist,需单独同步。 project_root = Path(__file__).resolve().parent.parent.parent + # 管理后台静态托管(/admin;须在 "/" 之前挂载) + admin_dist = project_root / settings.admin_dist_dir if not Path(settings.admin_dist_dir).is_absolute() else Path(settings.admin_dist_dir) + if admin_dist.is_dir(): + app.mount("/admin", StaticFiles(directory=str(admin_dist), html=True), name="admin") dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir) if dist.is_dir(): app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend") diff --git a/sdk/app/routers/admin.py b/sdk/app/routers/admin.py new file mode 100644 index 0000000..571562a --- /dev/null +++ b/sdk/app/routers/admin.py @@ -0,0 +1,149 @@ +"""routers/admin.py —— 管理后台端点(登录/用户管理/日志查看)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..auth import require_admin +from ..config import get_settings +from ..db import Database, get_db +from ..logwriter import log_event +from ..services.auth import ( + AUTHORIZATION_UNITS, + verify_login, + issue_token, + revoke_token, + list_users, + get_user_by_username, + create_user, + update_user, + delete_user, +) +from ..services.logview import query_logs +from ..upstream import envelope + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +class LoginBody(BaseModel): + username: str + password: str + + +class UserCreate(BaseModel): + username: str + password: str + role: str = "user" + authorizations: list[str] = [] + + +class UserPatch(BaseModel): + role: str | None = None + authorizations: list[str] | None = None + password: str | None = None + + +@router.post("/login") +async def login(body: LoginBody, db: Database = Depends(get_db)): + user = await verify_login(db, body.username, body.password) + if user is None: + log_event("admin_login_fail", detail={"username": body.username}) + content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401) + return JSONResponse(status_code=status, content=content) + if user["role"] != "admin": + log_event("admin_login_fail", detail={"username": body.username, "reason": "not_admin"}) + content, status = envelope("FORBIDDEN", "非管理员账号", status=403) + return JSONResponse(status_code=status, content=content) + token = await issue_token(db, user["id"]) + log_event("admin_login_ok", user_id=user["id"], detail={"username": body.username}) + return {"ok": True, "token": token, "user": user} + + +@router.post("/logout") +async def logout(request: Request, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + header = request.headers.get("Authorization", "") + token = header[7:] if header.startswith("Bearer ") else "" + if token: + await revoke_token(db, token) + return {"ok": True} + + +@router.get("/me") +async def me(_user: dict = Depends(require_admin)): + return {"ok": True, "user": _user} + + +@router.get("/units") +async def units(_user: dict = Depends(require_admin)): + return {"ok": True, "units": list(AUTHORIZATION_UNITS)} + + +@router.get("/users") +async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + return {"ok": True, "users": await list_users(db)} + + +@router.post("/users") +async def create_user_api(body: UserCreate, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + username = body.username.strip() + if not username: + content, _ = envelope("VALIDATION", "用户名不能为空", status=422) + return JSONResponse(status_code=422, content=content) + if not body.password: + content, _ = envelope("VALIDATION", "密码不能为空", status=422) + return JSONResponse(status_code=422, content=content) + if await get_user_by_username(db, username): + return JSONResponse( + status_code=409, + content={"ok": False, "error": {"code": "CONFLICT", "message": "用户名已存在", "degraded": False}}, + ) + user = await create_user(db, username, body.password, body.role, body.authorizations) + return {"ok": True, "user": user} + + +@router.patch("/users/{user_id}") +async def patch_user(user_id: str, body: UserPatch, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + user = await update_user(db, user_id, role=body.role, authorizations=body.authorizations, password=body.password) + if user is None: + content, _ = envelope("NOT_FOUND", status=404) + return JSONResponse(status_code=404, content=content) + return {"ok": True, "user": user} + + +@router.delete("/users/{user_id}") +async def delete_user_api(user_id: str, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + if user_id == _user["id"]: + return JSONResponse( + status_code=422, + content={"ok": False, "error": {"code": "VALIDATION", "message": "不能删除当前登录账号", "degraded": False}}, + ) + await delete_user(db, user_id) + return {"ok": True} + + +@router.get("/logs") +async def logs( + from_date: str | None = Query(None), + to_date: str | None = Query(None), + event: str | None = Query(None), + user_id: str | None = Query(None), + q: str | None = Query(None), + status: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + _user: dict = Depends(require_admin), +): + settings = get_settings() + result = await query_logs( + settings.log_dir, + from_date=from_date, + to_date=to_date, + event=event, + user_id=user_id, + q=q, + status=status, + page=page, + page_size=page_size, + ) + return {"ok": True, **result} diff --git a/sdk/app/services/auth.py b/sdk/app/services/auth.py new file mode 100644 index 0000000..0ae01c3 --- /dev/null +++ b/sdk/app/services/auth.py @@ -0,0 +1,189 @@ +"""services/auth.py —— 管理员鉴权:密码哈希、会话凭据、用户 CRUD、种子管理员。 + +密码用 stdlib pbkdf2_hmac + 每用户随机盐(不新增依赖); +会话凭据用不透明 token 存 auth_tokens,逐次查库(吊销/过期即时生效)。 +所有 DB 访问经 `db` 参数注入(与 services/history.py 一致,避免依赖全局单例)。 +""" +from __future__ import annotations + +import hashlib +import logging +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from ..config import get_settings +from ..db import Database, dumps_json, loads_json +from ..logwriter import log_event + +logger = logging.getLogger(__name__) + +# 授权清单功能单元(9 项,与管理后台界面一致;后续可调) +AUTHORIZATION_UNITS = ( + "qa", "kg", "extraction", "docgen", "figures", + "private_docs", "bioflow", "paper", "legacy", +) + +_PBKDF2_ITERATIONS = 200_000 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def hash_password(password: str, salt: str | None = None) -> tuple[str, str]: + salt = salt or secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS + ) + return digest.hex(), salt + + +def verify_password(password: str, salt: str, expected_hex: str) -> bool: + digest, _ = hash_password(password, salt) + return secrets.compare_digest(digest, expected_hex) + + +def _row_to_user(row: Any) -> dict[str, Any]: + return { + "id": row["id"], + "username": row["username"], + "role": row["role"], + "authorizations": loads_json(row["authorizations"], default=[]), + "created_at": row["created_at"], + } + + +# ---- 登录校验 ---- + +async def verify_login(db: Database, username: str, password: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT id, username, password_hash, salt, role, authorizations, created_at " + "FROM users WHERE username = ?", + (username,), + ) + if row is None: + return None + if not verify_password(password, row["salt"], row["password_hash"]): + return None + return _row_to_user(row) + + +# ---- 会话凭据 ---- + +async def issue_token(db: Database, user_id: str) -> str: + settings = get_settings() + token = secrets.token_urlsafe(32) + now = _now_iso() + expires = datetime.now(timezone.utc) + timedelta(seconds=settings.admin_token_ttl_seconds) + await db.execute( + "INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) " + "VALUES (?, ?, ?, ?, 0)", + (token, user_id, expires.strftime("%Y-%m-%dT%H:%M:%SZ"), now), + ) + return token + + +async def resolve_token(db: Database, token: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT t.expires_at, t.revoked, u.id, u.username, u.role, u.authorizations, u.created_at " + "FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?", + (token,), + ) + if row is None or row["revoked"]: + return None + try: + expires = datetime.strptime(row["expires_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return None + if datetime.now(timezone.utc) >= expires: + return None + return _row_to_user(row) + + +async def revoke_token(db: Database, token: str) -> None: + await db.execute("UPDATE auth_tokens SET revoked = 1 WHERE token = ?", (token,)) + + +# ---- 用户 CRUD ---- + +async def list_users(db: Database) -> list[dict[str, Any]]: + rows = await db.fetchall( + "SELECT id, username, role, authorizations, created_at FROM users ORDER BY created_at ASC" + ) + return [_row_to_user(r) for r in rows] + + +async def get_user(db: Database, user_id: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT id, username, role, authorizations, created_at FROM users WHERE id = ?", (user_id,) + ) + return _row_to_user(row) if row else None + + +async def get_user_by_username(db: Database, username: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT id, username, role, authorizations, created_at FROM users WHERE username = ?", (username,) + ) + return _row_to_user(row) if row else None + + +async def create_user( + db: Database, username: str, password: str, role: str, authorizations: list[str] +) -> dict[str, Any]: + user_id = str(uuid.uuid4()) + ph, salt = hash_password(password) + await db.execute( + "INSERT INTO users (id, username, password_hash, salt, role, authorizations, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (user_id, username, ph, salt, role, dumps_json(authorizations), _now_iso()), + ) + log_event("user_created", detail={"target": username, "role": role}) + return {"id": user_id, "username": username, "role": role, "authorizations": authorizations, "created_at": _now_iso()} + + +async def update_user( + db: Database, + user_id: str, + *, + role: str | None = None, + authorizations: list[str] | None = None, + password: str | None = None, +) -> dict[str, Any] | None: + fields: list[str] = [] + params: list[Any] = [] + if role is not None: + fields.append("role = ?") + params.append(role) + if authorizations is not None: + fields.append("authorizations = ?") + params.append(dumps_json(authorizations)) + if password is not None: + ph, salt = hash_password(password) + fields.append("password_hash = ?") + params.append(ph) + fields.append("salt = ?") + params.append(salt) + if fields: + params.append(user_id) + await db.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", params) + log_event("user_updated", detail={"target_id": user_id, "role": role, "password_reset": password is not None}) + return await get_user(db, user_id) + + +async def delete_user(db: Database, user_id: str) -> None: + await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + log_event("user_deleted", detail={"target_id": user_id}) + + +# ---- 种子管理员 ---- + +async def seed_admin(db: Database) -> None: + """启动时确保存在至少一个 admin 用户(默认 admin/admin)。""" + row = await db.fetchone("SELECT id FROM users WHERE role = 'admin' LIMIT 1") + if row is not None: + return + settings = get_settings() + await create_user(db, settings.admin_seed_username, settings.admin_seed_password, "admin", list(AUTHORIZATION_UNITS)) + logger.info("已创建种子管理员 %s", settings.admin_seed_username) diff --git a/sdk/app/services/logview.py b/sdk/app/services/logview.py new file mode 100644 index 0000000..b1cd8d0 --- /dev/null +++ b/sdk/app/services/logview.py @@ -0,0 +1,100 @@ +"""services/logview.py —— 读取行为日志文件(JSONL)+ 内存聚合,供管理后台日志查看。""" +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + + +def _select_files(log_dir: Path, from_date: str | None, to_date: str | None) -> list[Path]: + """按日期范围选出 audit-YYYY-MM-DD.log(升序)。""" + if not log_dir.is_dir(): + return [] + out: list[Path] = [] + for p in sorted(log_dir.glob("audit-*.log")): + name = p.name + if not (name.startswith("audit-") and name.endswith(".log")): + continue + stem = name[len("audit-"):-len(".log")] + if from_date and stem < from_date: + continue + if to_date and stem > to_date: + continue + out.append(p) + return out + + +def _read_lines(path: Path) -> list[dict[str, Any]]: + try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + except (FileNotFoundError, OSError): + return [] + out: list[dict[str, Any]] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except (json.JSONDecodeError, TypeError): + continue + return out + + +def _match(e: dict[str, Any], event, user_id, q, status) -> bool: + if event and e.get("event") != event: + return False + if user_id and e.get("user_id") != user_id: + return False + if status is not None and str(e.get("status")) != str(status): + return False + if q: + q = str(q) + if q not in str(e.get("path") or "") and q not in str(e.get("user_id") or ""): + return False + return True + + +async def query_logs( + log_dir: str, + *, + from_date: str | None = None, + to_date: str | None = None, + event: str | None = None, + user_id: str | None = None, + q: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 50, +) -> dict[str, Any]: + files = _select_files(Path(log_dir), from_date, to_date) + # 文件 I/O 放线程池,不阻塞事件循环 + all_lines = await asyncio.to_thread(lambda: [ln for p in files for ln in _read_lines(p)]) + items = [e for e in all_lines if _match(e, event, user_id, q, status)] + items.sort(key=lambda e: str(e.get("ts") or ""), reverse=True) + total = len(items) + + by_status: dict[str, int] = {} + latencies: list[float] = [] + denied = 0 + for e in items: + st = e.get("status") + if st is not None: + key = str(st) + by_status[key] = by_status.get(key, 0) + 1 + if e.get("event") in ("call_denied", "login_fail", "admin_login_fail"): + denied += 1 + lm = e.get("latency_ms") + if isinstance(lm, (int, float)): + latencies.append(float(lm)) + latencies.sort() + start = (page - 1) * page_size + aggregates = { + "by_status": by_status, + "denied": denied, + "avg_latency_ms": round(sum(latencies) / len(latencies), 1) if latencies else None, + "p95_latency_ms": latencies[int(len(latencies) * 0.95)] if latencies else None, + } + return {"items": items[start:start + page_size], "total": total, "aggregates": aggregates}