17 Commits

Author SHA1 Message Date
wmp
30360f1a7c 发布v0.3.0版本 2025-09-23 09:57:29 +08:00
wmp
0288855002 网关配置增加分页功能&更新分配装填布局 2025-09-22 18:40:41 +08:00
wmp
fd8fede301 更新字段枚举值展示&修复节点分页滑轮滚动和数据总数展示 2025-09-22 15:11:09 +08:00
wmp
826d8fc4c3 修复分配状态里的时间筛选查询问题 2025-09-22 10:31:49 +08:00
wmp
4f3671c8a6 更新布局和微调整页面样式 2025-09-20 17:03:16 +08:00
wmp
53feaa5e7d 发布版本 v0.2.0 2025-09-18 14:50:38 +08:00
wmp
9a5c6f8cb6 更新 bun.lock 文件 2025-09-17 18:24:54 +08:00
wmp
d5f7efc319 移除冗余的安装包 2025-09-17 18:14:11 +08:00
wmp
b39f6677a4 修复服务端组件中使用 SessionProvider 导致构建失败的问题 2025-09-17 17:57:22 +08:00
2bf90ee827 修复 prisma 导入路径问题 2025-09-17 17:31:01 +08:00
53dd2527f2 更新 bun.lock,修复配置问题 2025-09-17 16:46:16 +08:00
b27a409d0f 更新 prisma 配置,减小打包体积 2025-09-17 16:21:33 +08:00
wmp
8c9cb94d92 移除没必要安装包和文件 2025-09-17 15:51:30 +08:00
wmp
cb29e913f8 修复useSearchParams()编译错误添加 Suspense 边界&切换标签页时移除不必要的 mac 参数 2025-09-17 10:37:13 +08:00
wmp
3322d6a8e4 添加设置模块修复网关信息MAC地址跳转问题,删除冗余的文件和代码 2025-09-16 18:12:55 +08:00
wmp
a25ce604f0 添加数据库初始化脚本 2025-09-16 09:46:25 +08:00
57f1820338 添加 docker 脚本,修复构建版本冲突问题 2025-09-15 19:35:53 +08:00
30 changed files with 1691 additions and 973 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
src/generated/
.next
.env
deploy.sh
.volumes
.vscode
.git

5
.gitignore vendored
View File

@@ -1,4 +1,7 @@
node_modules node_modules
src/generated/
.next
.env .env
deploy.sh deploy.sh
.next .volumes
.vscode

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM oven/bun:1.2.19-alpine AS base
# 依赖缓存阶段
FROM base AS dep
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --registry https://registry.npmmirror.com
# 构建阶段
FROM base AS build
WORKDIR /app
COPY --from=dep /app/node_modules ./node_modules
COPY . .
RUN bun prisma generate
RUN bun run build
# 生产阶段
FROM base AS run
WORKDIR /app
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
USER bun
EXPOSE 3000/tcp
ENTRYPOINT [ "bun", "server.js" ]

119
bun.lock
View File

@@ -4,9 +4,8 @@
"": { "": {
"name": "my-app", "name": "my-app",
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.10.0",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@prisma/client": "^6.15.0", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@@ -15,30 +14,26 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.541.0", "lucide-react": "^0.541.0",
"next": "15.5.0", "next": "15.4.7",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^11.1.0",
"zustand": "^5.0.8", "zustand": "^5.0.8",
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^6.16.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/next-auth": "^3.15.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.0", "eslint-config-next": "15.5.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prisma": "^6.15.0", "prisma": "^6.16.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.20.4", "tsx": "^4.20.4",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
@@ -50,12 +45,6 @@
"packages": { "packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@auth/core": ["@auth/core@0.40.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw=="],
"@auth/prisma-adapter": ["@auth/prisma-adapter@2.10.0", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-EliOQoTjGK87jWWqnJvlQjbR4PjQZQqtwRwPAe108WwT9ubuuJJIrL68aNnQr4hFESz6P7SEX2bZy+y2yL37Gw=="],
"@babel/runtime": ["@babel/runtime@7.28.3", "", {}, "sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA=="],
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="], "@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
"@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="], "@emnapi/runtime": ["@emnapi/runtime@1.4.5", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg=="],
@@ -208,27 +197,25 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next-auth/prisma-adapter": ["@next-auth/prisma-adapter@1.0.7", "", { "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3", "next-auth": "^4" } }, "sha512-Cdko4KfcmKjsyHFrWwZ//lfLUbcLqlyFqjd/nYE2m3aZ7tjMNUjpks47iw7NTCnXf+5UWz5Ypyt1dSs1EP5QJw=="], "@next/env": ["@next/env@15.4.7", "https://registry.npmmirror.com/@next/env/-/env-15.4.7.tgz", {}, "sha512-PrBIpO8oljZGTOe9HH0miix1w5MUiGJ/q83Jge03mHEE0E3pyqzAy2+l5G6aJDbXoobmxPJTVhbCuwlLtjSHwg=="],
"@next/env": ["@next/env@15.5.0", "", {}, "sha512-sDaprBAfzCQiOgo2pO+LhnV0Wt2wBgartjrr+dpcTORYVnnXD0gwhHhiiyIih9hQbq+JnbqH4odgcFWhqCGidw=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.0", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-+k83U/fST66eQBjTltX2T9qUYd43ntAe+NZ5qeZVTQyTiFiHvTLtkpLKug4AnZAtuI/lwz5tl/4QDJymjVkybg=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.0", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-+k83U/fST66eQBjTltX2T9qUYd43ntAe+NZ5qeZVTQyTiFiHvTLtkpLKug4AnZAtuI/lwz5tl/4QDJymjVkybg=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v7Jj9iqC6enxIRBIScD/o0lH7QKvSxq2LM8UTyqJi+S2w2QzhMYjven4vgu/RzgsdtdbpkyCxBTzHl/gN5rTRg=="], "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.4.7", "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.4.7.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-2Dkb+VUTp9kHHkSqtws4fDl2Oxms29HcZBwFIda1X7Ztudzy7M6XF9HDS2dq85TmdN47VpuhjE+i6wgnIboVzQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-s2Nk6ec+pmYmAb/utawuURy7uvyYKDk+TRE5aqLRsdnj3AhwC9IKUBmhfnLmY/+P+DnwqpeXEFIKe9tlG0p6CA=="], "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.4.7", "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.4.7.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-qaMnEozKdWezlmh1OGDVFueFv2z9lWTcLvt7e39QA3YOvZHNpN2rLs/IQLwZaUiw2jSvxW07LxMCWtOqsWFNQg=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-mGlPJMZReU4yP5fSHjOxiTYvZmwPSWn/eF/dcg21pwfmiUCKS1amFvf1F1RkLHPIMPfocxLViNWFvkvDB14Isg=="], "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.4.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.4.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-ny7lODPE7a15Qms8LZiN9wjNWIeI+iAZOFDOnv2pcHStncUr7cr9lD5XF81mdhrBXLUP9yT9RzlmSWKIazWoDw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-biWqIOE17OW/6S34t1X8K/3vb1+svp5ji5QQT/IKR+VfM3B7GvlCwmz5XtlEan2ukOUf9tj2vJJBffaGH4fGRw=="], "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.4.7", "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.4.7.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-4SaCjlFR/2hGJqZLLWycccy1t+wBrE/vyJWnYaZJhUVHccpGLG5q0C+Xkw4iRzUIkE+/dr90MJRUym3s1+vO8A=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zPisT+obYypM/l6EZ0yRkK3LEuoZqHaSoYKj+5jiD9ESHwdr6QhnabnNxYkdy34uCigNlWIaCbjFmQ8FY5AlxA=="], "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.4.7", "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.4.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-2uNXjxvONyRidg00VwvlTYDwC9EgCGNzPAPYbttIATZRxmOZ3hllk/YYESzHZb65eyZfBR5g9xgCZjRAl9YYGg=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+t3+7GoU9IYmk+N+FHKBNFdahaReoAktdOpXHFIPOU1ixxtdge26NgQEEkJkCw2dHT9UwwK5zw4mAsURw4E8jA=="], "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.4.7", "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.4.7.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ceNbPjsFgLscYNGKSu4I6LYaadq2B8tcK116nVuInpHHdAWLWSwVK6CHNvCi0wVS9+TTArIFKJGsEyVD1H+4Kg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-d8MrXKh0A+c9DLiy1BUFwtg3Hu90Lucj3k6iKTUdPOv42Ve2UiIG8HYi3UAb8kFVluXxEfdpCoPPCSODk5fDcw=="], "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.4.7", "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.4.7.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-pZyxmY1iHlZJ04LUL7Css8bNvsYAMYOY9JRwFA3HZgpaNKsJSowD09Vg2R9734GxAcLJc2KDQHSCR91uD6/AAw=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Fe1tGHxOWEyQjmygWkkXSwhFcTJuimrNu52JEuwItrKJVV4iRjbWp9I7zZjwqtiNnQmxoEvoisn8wueFLrNpvQ=="], "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.4.7", "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.4.7.tgz", { "os": "win32", "cpu": "x64" }, "sha512-HjuwPJ7BeRzgl3KrjKqD2iDng0eQIpIReyhpF5r4yeAHFwWRuAhfW92rWv/r3qeQHEwHsLRzFDvMqRjyM5DI6A=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -238,21 +225,19 @@
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
"@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], "@prisma/client": ["@prisma/client@6.16.2", "https://registry.npmmirror.com/@prisma/client/-/client-6.16.2.tgz", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw=="],
"@prisma/client": ["@prisma/client@6.15.0", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-wR2LXUbOH4cL/WToatI/Y2c7uzni76oNFND7+23ypLllBmIS8e3ZHhO+nud9iXSXKFt1SoM3fTZvHawg63emZw=="], "@prisma/config": ["@prisma/config@6.16.1", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, "sha512-sz3uxRPNL62QrJ0EYiujCFkIGZ3hg+9hgC1Ae1HjoYuj0BxCqHua4JNijYvYCrh9LlofZDZcRBX3tHBfLvAngA=="],
"@prisma/config": ["@prisma/config@6.15.0", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.16.12", "empathic": "2.0.0" } }, "sha512-KMEoec9b2u6zX0EbSEx/dRpx1oNLjqJEBZYyK0S3TTIbZ7GEGoVyGyFRk4C72+A38cuPLbfQGQvgOD+gBErKlA=="], "@prisma/debug": ["@prisma/debug@6.16.1", "", {}, "sha512-RWv/VisW5vJE4cDRTuAHeVedtGoItXTnhuLHsSlJ9202QKz60uiXWywBlVcqXVq8bFeIZoCoWH+R1duZJPwqLw=="],
"@prisma/debug": ["@prisma/debug@6.15.0", "", {}, "sha512-y7cSeLuQmyt+A3hstAs6tsuAiVXSnw9T55ra77z0nbNkA8Lcq9rNcQg6PI00by/+WnE/aMRJ/W7sZWn2cgIy1g=="], "@prisma/engines": ["@prisma/engines@6.16.1", "", { "dependencies": { "@prisma/debug": "6.16.1", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "@prisma/fetch-engine": "6.16.1", "@prisma/get-platform": "6.16.1" } }, "sha512-EOnEM5HlosPudBqbI+jipmaW/vQEaF0bKBo4gVkGabasINHR6RpC6h44fKZEqx4GD8CvH+einD2+b49DQrwrAg=="],
"@prisma/engines": ["@prisma/engines@6.15.0", "", { "dependencies": { "@prisma/debug": "6.15.0", "@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "@prisma/fetch-engine": "6.15.0", "@prisma/get-platform": "6.15.0" } }, "sha512-opITiR5ddFJ1N2iqa7mkRlohCZqVSsHhRcc29QXeldMljOf4FSellLT0J5goVb64EzRTKcIDeIsJBgmilNcKxA=="], "@prisma/engines-version": ["@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "", {}, "sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA=="],
"@prisma/engines-version": ["@prisma/engines-version@6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "", {}, "sha512-a/46aK5j6L3ePwilZYEgYDPrhBQ/n4gYjLxT5YncUTJJNRnTCVjPF86QdzUOLRdYjCLfhtZp9aum90W0J+trrg=="], "@prisma/fetch-engine": ["@prisma/fetch-engine@6.16.1", "", { "dependencies": { "@prisma/debug": "6.16.1", "@prisma/engines-version": "6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43", "@prisma/get-platform": "6.16.1" } }, "sha512-fl/PKQ8da5YTayw86WD3O9OmKJEM43gD3vANy2hS5S1CnfW2oPXk+Q03+gUWqcKK306QqhjjIHRFuTZ31WaosQ=="],
"@prisma/fetch-engine": ["@prisma/fetch-engine@6.15.0", "", { "dependencies": { "@prisma/debug": "6.15.0", "@prisma/engines-version": "6.15.0-5.85179d7826409ee107a6ba334b5e305ae3fba9fb", "@prisma/get-platform": "6.15.0" } }, "sha512-xcT5f6b+OWBq6vTUnRCc7qL+Im570CtwvgSj+0MTSGA1o9UDSKZ/WANvwtiRXdbYWECpyC3CukoG3A04VTAPHw=="], "@prisma/get-platform": ["@prisma/get-platform@6.16.1", "", { "dependencies": { "@prisma/debug": "6.16.1" } }, "sha512-kUfg4vagBG7dnaGRcGd1c0ytQFcDj2SUABiuveIpL3bthFdTLI6PJeLEia6Q8Dgh+WhPdo0N2q0Fzjk63XTyaA=="],
"@prisma/get-platform": ["@prisma/get-platform@6.15.0", "", { "dependencies": { "@prisma/debug": "6.15.0" } }, "sha512-Jbb+Xbxyp05NSR1x2epabetHiXvpO8tdN2YNoWoA/ZsbYyxxu/CO/ROBauIFuMXs3Ti+W7N7SJtWsHGaWte9Rg=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
@@ -266,6 +251,8 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
@@ -362,8 +349,6 @@
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/next-auth": ["@types/next-auth@3.15.0", "", { "dependencies": { "next-auth": "*" } }, "sha512-ZVfejlu81YiIRX1m0iKAfvZ3nK7K9EyZWhNARNKsFop8kNAgEvMnlKpTpwN59xkK2OhyWLagPuiDAVBYSO9jSA=="],
"@types/node": ["@types/node@20.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow=="], "@types/node": ["@types/node@20.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow=="],
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="], "@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
@@ -462,8 +447,6 @@
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="], "axe-core": ["axe-core@4.10.3", "", {}, "sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg=="],
@@ -478,8 +461,6 @@
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.25.3", "", { "dependencies": { "caniuse-lite": "^1.0.30001735", "electron-to-chromium": "^1.5.204", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-cDGv1kkDI4/0e5yON9yM5G/0A5u8sf5TnmdX5C9qHzI9PPu++sQ9zjm1k9NiOrf3riY4OkK0zSGqfvJyJsgCBQ=="],
"c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
@@ -520,8 +501,6 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
@@ -560,8 +539,6 @@
"effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="], "effect": ["effect@3.16.12", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg=="],
"electron-to-chromium": ["electron-to-chromium@1.5.208", "", {}, "sha512-ozZyibehoe7tOhNaf16lKmljVf+3npZcJIEbJRVftVsmAg5TeA1mGS9dVCZzOwr2xT7xK15V0p7+GZqSPgkuPg=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="],
@@ -586,8 +563,6 @@
"esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="], "esbuild": ["esbuild@0.25.9", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.9", "@esbuild/android-arm": "0.25.9", "@esbuild/android-arm64": "0.25.9", "@esbuild/android-x64": "0.25.9", "@esbuild/darwin-arm64": "0.25.9", "@esbuild/darwin-x64": "0.25.9", "@esbuild/freebsd-arm64": "0.25.9", "@esbuild/freebsd-x64": "0.25.9", "@esbuild/linux-arm": "0.25.9", "@esbuild/linux-arm64": "0.25.9", "@esbuild/linux-ia32": "0.25.9", "@esbuild/linux-loong64": "0.25.9", "@esbuild/linux-mips64el": "0.25.9", "@esbuild/linux-ppc64": "0.25.9", "@esbuild/linux-riscv64": "0.25.9", "@esbuild/linux-s390x": "0.25.9", "@esbuild/linux-x64": "0.25.9", "@esbuild/netbsd-arm64": "0.25.9", "@esbuild/netbsd-x64": "0.25.9", "@esbuild/openbsd-arm64": "0.25.9", "@esbuild/openbsd-x64": "0.25.9", "@esbuild/openharmony-arm64": "0.25.9", "@esbuild/sunos-x64": "0.25.9", "@esbuild/win32-arm64": "0.25.9", "@esbuild/win32-ia32": "0.25.9", "@esbuild/win32-x64": "0.25.9" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.33.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA=="], "eslint": ["eslint@9.33.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.1", "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.33.0", "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA=="],
@@ -650,8 +625,6 @@
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -768,8 +741,6 @@
"jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="], "jiti": ["jiti@2.5.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w=="],
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="],
@@ -820,8 +791,6 @@
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"lucide-react": ["lucide-react@0.541.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-s0Vircsu5WaGv2KoJZ5+SoxiAJ3UXV5KqEM3eIFDHaHkcLIFdIWgXtZ412+Gh02UsdS7Was+jvEpBvPCWQISlg=="], "lucide-react": ["lucide-react@0.541.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-s0Vircsu5WaGv2KoJZ5+SoxiAJ3UXV5KqEM3eIFDHaHkcLIFdIWgXtZ412+Gh02UsdS7Was+jvEpBvPCWQISlg=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],
@@ -850,28 +819,16 @@
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@15.5.0", "", { "dependencies": { "@next/env": "15.5.0", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.0", "@next/swc-darwin-x64": "15.5.0", "@next/swc-linux-arm64-gnu": "15.5.0", "@next/swc-linux-arm64-musl": "15.5.0", "@next/swc-linux-x64-gnu": "15.5.0", "@next/swc-linux-x64-musl": "15.5.0", "@next/swc-win32-arm64-msvc": "15.5.0", "@next/swc-win32-x64-msvc": "15.5.0", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-N1lp9Hatw3a9XLt0307lGB4uTKsXDhyOKQo7uYMzX4i0nF/c27grcGXkLdb7VcT8QPYLBa8ouIyEoUQJ2OyeNQ=="], "next": ["next@15.4.7", "https://registry.npmmirror.com/next/-/next-15.4.7.tgz", { "dependencies": { "@next/env": "15.4.7", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.4.7", "@next/swc-darwin-x64": "15.4.7", "@next/swc-linux-arm64-gnu": "15.4.7", "@next/swc-linux-arm64-musl": "15.4.7", "@next/swc-linux-x64-gnu": "15.4.7", "@next/swc-linux-x64-musl": "15.4.7", "@next/swc-win32-arm64-msvc": "15.4.7", "@next/swc-win32-x64-msvc": "15.4.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-OcqRugwF7n7mC8OSYjvsZhhG1AYSvulor1EIUsIkbbEbf1qoE5EbH36Swj8WhF4cHqmDgkiam3z1c1W0J1Wifg=="],
"next-auth": ["next-auth@5.0.0-beta.29", "", { "dependencies": { "@auth/core": "0.40.0" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "next": "^14.0.0-0 || ^15.0.0-0", "nodemailer": "^6.6.5", "react": "^18.2.0 || ^19.0.0-0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A=="],
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="],
"normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="],
"nypm": ["nypm@0.6.1", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.2.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w=="], "nypm": ["nypm@0.6.1", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", "pkg-types": "^2.2.0", "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w=="],
"oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="],
"oauth4webapi": ["oauth4webapi@3.8.1", "", {}, "sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
@@ -888,10 +845,6 @@
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"oidc-token-hash": ["oidc-token-hash@5.1.1", "", {}, "sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g=="],
"openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
@@ -922,17 +875,9 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"preact": ["preact@10.27.1", "", {}, "sha512-V79raXEWch/rbqoNc7nT9E4ep7lu+mI3+sBmfRD4i1M73R3WLYcCtdI0ibxGVf4eQL8ZIz2nFacqEC+rmnOORQ=="],
"preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], "prisma": ["prisma@6.16.1", "", { "dependencies": { "@prisma/config": "6.16.1", "@prisma/engines": "6.16.1" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-MFkMU0eaDDKAT4R/By2IA9oQmwLTxokqv2wegAErr9Rf+oIe7W2sYpE/Uxq0H2DliIR7vnV63PkC1bEwUtl98w=="],
"prisma": ["prisma@6.15.0", "", { "dependencies": { "@prisma/config": "6.15.0", "@prisma/engines": "6.15.0" }, "peerDependencies": { "typescript": ">=5.1.0" }, "optionalPeers": ["typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-E6RCgOt+kUVtjtZgLQDBJ6md2tDItLJNExwI0XJeBc1FKL+Vwb+ovxXxuok9r8oBgsOXBA33fGDuE/0qDdCWqQ=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
@@ -1078,16 +1023,12 @@
"unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="],
"update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@@ -1108,12 +1049,6 @@
"zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="], "zustand": ["zustand@5.0.8", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="],
"@auth/core/jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="],
"@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="],
"@auth/core/preact-render-to-string": ["preact-render-to-string@6.5.11", "", { "peerDependencies": { "preact": ">=10" } }, "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="],
@@ -1130,8 +1065,6 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/next-auth/next-auth": ["next-auth@4.24.11", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.2", "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "@typescript-eslint/typescript-estree/fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -1152,16 +1085,12 @@
"is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "is-bun-module/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"lru-cache/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "sharp/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@types/next-auth/next-auth/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
"@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "@typescript-eslint/typescript-estree/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],

10
docker-compose.yaml Normal file
View File

@@ -0,0 +1,10 @@
services:
mariadb:
image: mariadb:10
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: app
ports:
- "23306:3306"
volumes:
- .volumes/mysql:/var/lib/mysql

1
next-env.d.ts vendored
View File

@@ -1,6 +1,5 @@
/// <reference types="next" /> /// <reference types="next" />
/// <reference types="next/image-types/global" /> /// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited // NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,6 +1,6 @@
{ {
"name": "my-app", "name": "my-app",
"version": "0.1.0", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -8,13 +8,9 @@
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.10.0",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
"@prisma/client": "^6.15.0", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
@@ -23,35 +19,30 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.541.0", "lucide-react": "^0.541.0",
"next": "15.5.0", "next": "15.4.7",
"next-auth": "^5.0.0-beta.29",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hook-form": "^7.62.0", "react-hook-form": "^7.62.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.3.1",
"uuid": "^11.1.0",
"zustand": "^5.0.8" "zustand": "^5.0.8"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^6.16.2",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/next-auth": "^3.15.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.5.0", "eslint-config-next": "15.5.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prisma": "^6.15.0", "prisma": "^6.16.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"tsx": "^4.20.4", "tsx": "^4.20.4",
"tw-animate-css": "^1.3.7", "tw-animate-css": "^1.3.7",
"typescript": "^5", "typescript": "^5",
"zod": "^4.1.5" "zod": "^4.1.5"
}, }
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }

57
prisma/init.sql Normal file
View File

@@ -0,0 +1,57 @@
-- jdbox.accounts definition
CREATE TABLE `accounts` (
`id` varchar(191) NOT NULL,
`user_id` int(11) NOT NULL,
`type` varchar(191) NOT NULL,
`provider` varchar(191) NOT NULL,
`provider_account_id` varchar(191) NOT NULL,
`refresh_token` text DEFAULT NULL,
`access_token` text DEFAULT NULL,
`expires_at` int(11) DEFAULT NULL,
`token_type` varchar(191) DEFAULT NULL,
`scope` varchar(191) DEFAULT NULL,
`id_token` text DEFAULT NULL,
`session_state` varchar(191) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `accounts_provider_provider_account_id_key` (`provider`, `provider_account_id`),
KEY `accounts_user_id_fkey` (`user_id`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- jdbox.sessions definition
CREATE TABLE `sessions` (
`id` varchar(191) NOT NULL,
`expires` datetime(3) NOT NULL,
`createdAt` datetime(3) NOT NULL DEFAULT current_timestamp(3),
`userId` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `sessions_userId_idx` (`userId`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- jdbox.users definition
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(191) DEFAULT NULL,
`createdAt` datetime(3) NOT NULL DEFAULT current_timestamp(3),
`password` varchar(191) NOT NULL,
`account` varchar(191) NOT NULL,
`updatedAt` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `users_phone_key` (`phone`)
) ENGINE = InnoDB AUTO_INCREMENT = 2 DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- jdbox.verification_codes definition
CREATE TABLE `verification_codes` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`phone` varchar(191) NOT NULL,
`code` varchar(191) NOT NULL,
`type` varchar(191) NOT NULL,
`expiresAt` datetime(3) NOT NULL,
`createdAt` datetime(3) NOT NULL DEFAULT current_timestamp(3),
PRIMARY KEY (`id`),
KEY `verification_codes_phone_type_idx` (`phone`, `type`) USING BTREE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
-- 插入初始用户
INSERT INTO users(phone, password, name)
VALUES(
'admin',
'$2a$10$k.p3.s28OdLmGCMtuvBoqOxABp03h0Zhmop4eqqlR8sIjkThCcsnS',
'管理员'
);

View File

@@ -1,16 +0,0 @@
import { PrismaClient } from '@prisma/client'
declare global {
var cachedPrisma: PrismaClient
}
export let prisma: PrismaClient
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient()
} else {
if (!global.cachedPrisma) {
global.cachedPrisma = new PrismaClient()
}
prisma = global.cachedPrisma
}

View File

@@ -1,6 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client"
binaryTargets = ["native", "debian-openssl-3.0.x"] output = "../src/generated/prisma"
} }
datasource db { datasource db {
@@ -123,15 +123,14 @@ model Account {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
phone String @unique account String @unique
password String password String
name String? name String?
verifiedPhone Boolean @default(false) createdAt DateTime @default(now())
createdAt DateTime @default(now()) updatedAt DateTime @updatedAt
updatedAt DateTime @updatedAt sessions Session[]
sessions Session[] accounts Account[]
accounts Account[]
@@map("users") @@map("users")
} }
@@ -149,12 +148,12 @@ model Session {
model VerificationCode { model VerificationCode {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
phone String account String
code String code String
type String type String
expiresAt DateTime expiresAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([phone, type]) @@index([account, type])
@@map("verification_codes") @@map("verification_codes")
} }

View File

@@ -1,48 +0,0 @@
import { PrismaClient } from '@prisma/client'
import { hash } from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
console.log('🚀 开始执行种子脚本...')
try {
// 首先检查用户是否已存在
const existingUser = await prisma.user.findUnique({
where: { phone: '17516219072' }
})
if (existingUser) {
console.log('✅ 用户已存在:', existingUser)
return
}
console.log('🔐 加密密码...')
const password = await hash('123456', 10)
console.log('✅ 加密完成')
console.log('👤 创建用户...')
const user = await prisma.user.create({
data: {
phone: '17516219072',
password: password,
name: '测试用户',
},
})
console.log('✅ 用户创建成功:', user)
} catch (error) {
console.error('❌ 种子脚本错误:', error)
throw error
}
}
main()
.catch((e) => {
console.error('❌ 种子脚本执行失败:', e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
console.log('🔚 数据库连接已关闭')
})

View File

@@ -8,16 +8,13 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Lock, Phone } from 'lucide-react' import { Lock, User } from 'lucide-react'
import { useAuthStore } from '@/store/auth' import { useAuthStore } from '@/store/auth'
import { toast, Toaster } from 'sonner' import { toast, Toaster } from 'sonner'
const formSchema = z.object({ const formSchema = z.object({
phone: z.string() account: z.string().min(3, '账号至少需要3个字符'),
.min(11, '手机号必须是11位') password: z.string().min(6, '密码至少需要6个字符'),
.max(11, '手机号必须是11位')
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
password: z.string().min(6, '密码至少需要6个字符'),
}) })
export default function LoginPage() { export default function LoginPage() {
@@ -27,7 +24,7 @@ export default function LoginPage() {
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
phone: '', account: '',
password: '', password: '',
}, },
}) })
@@ -78,18 +75,17 @@ export default function LoginPage() {
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField <FormField
control={form.control} control={form.control}
name="phone" name="account"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel></FormLabel> <FormLabel></FormLabel>
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Phone className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <User className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="请输入手机号" placeholder="请输入您的账号"
className="pl-8" className="pl-8"
{...field} {...field}
maxLength={11}
/> />
</div> </div>
</FormControl> </FormControl>

View File

@@ -4,23 +4,22 @@ import { compare } from 'bcryptjs'
import { z } from 'zod' import { z } from 'zod'
const loginSchema = z.object({ const loginSchema = z.object({
phone: z.string() account: z.string().min(3, '账号至少需要3个字符'),
.min(11, '手机号必须是11位')
.max(11, '手机号必须是11位')
.regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
password: z.string().min(6, '密码至少需要6个字符'), password: z.string().min(6, '密码至少需要6个字符'),
}) })
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const body = await request.json() const body = await request.json()
const { phone, password } = loginSchema.parse(body) const { account, password } = loginSchema.parse(body)
// 查找用户 - 使用正确的查询方式 const user = await prisma.user.findFirst({
const user = await prisma.user.findUnique({
where: { where: {
phone: phone.trim() // 去除空格 OR: [
{ account: account.trim() },
{ password: account.trim() }
]
}, },
}) })
@@ -57,7 +56,7 @@ export async function POST(request: Request) {
success: true, success: true,
user: { user: {
id: user.id, id: user.id,
phone: user.phone, account: user.account,
name: user.name name: user.name
} }
}) })

View File

@@ -23,7 +23,7 @@ export async function GET(request: NextRequest) {
case 'city_node_count': case 'city_node_count':
return await getCityNodeCount() return await getCityNodeCount()
case 'allocation_status': case 'allocation_status':
return await getAllocationStatus() return await getAllocationStatus(request)
case 'edge_nodes': case 'edge_nodes':
return await getEdgeNodes(request) return await getEdgeNodes(request)
default: default:
@@ -54,17 +54,76 @@ async function getGatewayInfo() {
async function getGatewayConfig(request: NextRequest) { async function getGatewayConfig(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const macAddress = searchParams.get('mac') || '000C29DF1647' const macAddress = searchParams.get('mac') || ''
const offset = parseInt(searchParams.get('offset') || '0')
const limit = parseInt(searchParams.get('limit') || '100')
// 使用参数化查询防止SQL注入 // 定义类型接口
const result = await prisma.$queryRaw` interface GatewayRecord {
SELECT edge, city, user, public, inner_ip, ischange, isonline edge: string | null;
FROM gateway city: string | null;
LEFT JOIN cityhash ON cityhash.hash = gateway.cityhash user: string | null;
LEFT JOIN edge ON edge.macaddr = gateway.edge public: string | null;
WHERE gateway.macaddr = ${macAddress}; inner_ip: string | null;
` ischange: boolean | number | null;
return NextResponse.json(safeSerialize(result)) isonline: boolean | number | null;
}
// 获取总数
let totalCountQuery = ''
let totalCountParams: (string | number)[] = []
if (macAddress) {
totalCountQuery = `
SELECT COUNT(*) as total
FROM gateway
LEFT JOIN cityhash ON cityhash.hash = gateway.cityhash
LEFT JOIN edge ON edge.macaddr = gateway.edge
WHERE gateway.macaddr = ?
`
totalCountParams = [macAddress]
} else {
totalCountQuery = `
SELECT COUNT(*) as total
FROM gateway
`
}
const totalCountResult = await prisma.$queryRawUnsafe<[{ total: bigint }]>(
totalCountQuery,
...totalCountParams
)
const totalCount = Number(totalCountResult[0]?.total || 0)
// 获取分页数据
let query = `
select edge, city, user, public, inner_ip, ischange, isonline
from
gateway
left join cityhash
on cityhash.hash = gateway.cityhash
left join edge
on edge.macaddr = gateway.edge
`
let params: (string | number)[] = []
if (macAddress) {
query += ' WHERE gateway.macaddr = ?'
params = [macAddress]
} else {
query += ' LIMIT ? OFFSET ?'
params.push(limit, offset)
}
// 指定返回类型
const result = await prisma.$queryRawUnsafe<GatewayRecord[]>(query, ...params)
return NextResponse.json({
data: safeSerialize(result),
totalCount: totalCount,
currentPage: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(totalCount / limit)
})
} catch (error) { } catch (error) {
console.error('Gateway config query error:', error) console.error('Gateway config query error:', error)
return NextResponse.json({ error: '查询网关配置失败' }, { status: 500 }) return NextResponse.json({ error: '查询网关配置失败' }, { status: 500 })
@@ -91,11 +150,23 @@ async function getCityConfigCount() {
async function getCityNodeCount() { async function getCityNodeCount() {
try { try {
const result = await prisma.$queryRaw` const result = await prisma.$queryRaw`
SELECT c.city, c.hash, c.label, COUNT(e.id) as count, c.offset select c.city, c.hash, c.label, e.count, c.\`offset\`
FROM cityhash c from
LEFT JOIN edge e ON c.id = e.city_id cityhash c
GROUP BY c.hash, c.city, c.label, c.offset left join (
ORDER BY count DESC select city_id, count(*) as count
from
edge
where
edge.active is true
group by
city_id
) e
on c.id = e.city_id
group by
c.hash
order by
count desc
` `
return NextResponse.json(safeSerialize(result)) return NextResponse.json(safeSerialize(result))
} catch (error) { } catch (error) {
@@ -105,42 +176,47 @@ async function getCityNodeCount() {
} }
// 城市分配状态 // 城市分配状态
async function getAllocationStatus() { async function getAllocationStatus(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url)
const hours = searchParams.get('hours') || '24'
const hoursNum = parseInt(hours) || 24
// 使用参数化查询防止SQL注入 // 使用参数化查询防止SQL注入
const result = await prisma.$queryRaw` const result = await prisma.$queryRaw`
SELECT SELECT
city, city,
c1.count AS count, c1.count AS count,
c2.assigned AS assigned c2.assigned AS assigned
FROM FROM
cityhash cityhash
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
city_id, city_id,
COUNT(*) AS count COUNT(*) AS count
FROM FROM
edge edge
WHERE WHERE
active = 1 active = 1
GROUP BY GROUP BY
city_id city_id
) c1 ON c1.city_id = cityhash.id ) c1 ON c1.city_id = cityhash.id
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
city AS city_id, city AS city_id,
COUNT(*) AS assigned COUNT(*) AS assigned
FROM FROM
\`change\` \`change\`
WHERE WHERE
time > NOW() - INTERVAL 1 DAY time > NOW() - INTERVAL ${hoursNum} HOUR
GROUP BY GROUP BY
city city
) c2 ON c2.city_id = cityhash.id ) c2 ON c2.city_id = cityhash.id
WHERE WHERE
cityhash.macaddr IS NOT NULL; cityhash.macaddr IS NOT NULL;
` `
return NextResponse.json(safeSerialize(result)) return NextResponse.json(safeSerialize(result))
} catch (error) { } catch (error) {
console.error('Allocation status query error:', error) console.error('Allocation status query error:', error)
const errorMessage = error instanceof Error ? error.message : 'Unknown error' const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -155,18 +231,32 @@ async function getAllocationStatus() {
async function getEdgeNodes(request: NextRequest) { async function getEdgeNodes(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const threshold = searchParams.get('threshold') || '20' const offset = parseInt(searchParams.get('offset') || '0')
const limit = searchParams.get('limit') || '100' const limit = parseInt(searchParams.get('limit') || '100')
// 获取总数 - 使用类型断言
const totalCountResult = await prisma.$queryRaw<[{ total: bigint }]>`
SELECT COUNT(*) as total
FROM edge
WHERE active = true
`
const totalCount = Number(totalCountResult[0]?.total || 0)
// 使用参数化查询防止SQL注入 // 获取分页数据
const result = await prisma.$queryRaw` const result = await prisma.$queryRaw`
SELECT edge.id, edge.macaddr, city, public, isp, single, sole, arch, online SELECT edge.id, edge.macaddr, city, public, isp, single, sole, arch, online
FROM edge FROM edge
LEFT JOIN cityhash ON cityhash.id = edge.city_id LEFT JOIN cityhash ON cityhash.id = edge.city_id
WHERE edge.id > ${threshold} AND active = true WHERE edge.active = true
LIMIT ${limit} ORDER BY edge.id
` LIMIT ${limit} OFFSET ${offset}
return NextResponse.json(safeSerialize(result)) `
return NextResponse.json({
data: safeSerialize(result),
totalCount: totalCount,
currentPage: Math.floor(offset / limit) + 1,
totalPages: Math.ceil(totalCount / limit)
})
} catch (error) { } catch (error) {
console.error('Edge nodes query error:', error) console.error('Edge nodes query error:', error)
return NextResponse.json({ error: '查询边缘节点失败' }, { status: 500 }) return NextResponse.json({ error: '查询边缘节点失败' }, { status: 500 })

132
src/app/api/users/route.tsx Normal file
View File

@@ -0,0 +1,132 @@
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { hash } from 'bcryptjs'
// 获取所有用户
export async function GET() {
try {
const users = await prisma.user.findMany({
select: {
id: true,
account: true,
name: true,
createdAt: true,
updatedAt: true,
},
orderBy: {
createdAt: 'desc',
},
})
return NextResponse.json({
success: true,
users
})
} catch (error) {
console.error('获取用户列表错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 }
)
}
}
// 创建用户
export async function POST(request: Request) {
try {
const { account, password, name } = await request.json()
// 检查用户是否已存在
const existingUser = await prisma.user.findUnique({
where: { account }
})
if (existingUser) {
return NextResponse.json(
{ success: false, error: '用户账号已存在' },
{ status: 400 }
)
}
// 加密密码
const hashedPassword = await hash(password, 10)
// 创建用户
const user = await prisma.user.create({
data: {
account,
password: hashedPassword,
name: name || account,
},
})
// 不返回密码字段
const { password: _, ...userWithoutPassword } = user
return NextResponse.json({
success: true,
user: userWithoutPassword
})
} catch (error) {
console.error('创建用户错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 }
)
}
}
// 删除用户
export async function DELETE(request: Request) {
try {
// 从URL中获取查询参数
const url = new URL(request.url)
const id = url.searchParams.get('id')
if (!id) {
return NextResponse.json(
{ success: false, error: '用户ID不能为空' },
{ status: 400 }
)
}
const userId = parseInt(id)
if (isNaN(userId)) {
return NextResponse.json(
{ success: false, error: '无效的用户ID' },
{ status: 400 }
)
}
// 检查用户是否存在
const existingUser = await prisma.user.findUnique({
where: { id: userId }
})
if (!existingUser) {
return NextResponse.json(
{ success: false, error: '用户不存在' },
{ status: 404 }
)
}
// 删除用户
await prisma.user.delete({
where: { id: userId }
})
return NextResponse.json({
success: true,
message: '用户删除成功'
})
} catch (error) {
console.error('删除用户错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 }
)
}
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
import { formatNumber, validateNumber } from '@/lib/formatters' import { formatNumber, validateNumber } from '@/lib/formatters'
import LoadingCard from '@/components/ui/loadingCard' import LoadingCard from '@/components/ui/loadingCard'
import ErrorCard from '@/components/ui/errorCard' import ErrorCard from '@/components/ui/errorCard'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
interface AllocationStatus { interface AllocationStatus {
city: string city: string
@@ -22,58 +23,34 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
const [data, setData] = useState<AllocationStatus[]>([]) const [data, setData] = useState<AllocationStatus[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [timeFilter, setTimeFilter] = useState('24h') // 默认24小时 const [timeFilter, setTimeFilter] = useState('24') // 默认24小时
const [customTime, setCustomTime] = useState('') const [customHours, setCustomHours] = useState('')
// 生成时间筛选条件 // 获取时间参数(小时数)
const getTimeCondition = useCallback(() => { const getTimeHours = useCallback(() => {
if (timeFilter === 'custom' && customTime) {
// 将datetime-local格式转换为SQL datetime格式
return customTime.replace('T', ' ') + ':00'
}
const now = new Date()
let filterDate
switch(timeFilter) { if (timeFilter === 'custom' && customHours) {
case '1h': const hours = parseInt(customHours)
filterDate = new Date(now.getTime() - 60 * 60 * 1000) return isNaN(hours) ? 24 : Math.max(1, hours) // 默认24小时最少1小时
break
case '6h':
filterDate = new Date(now.getTime() - 6 * 60 * 60 * 1000)
break
case '12h':
filterDate = new Date(now.getTime() - 12 * 60 * 60 * 1000)
break
case '24h':
filterDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
case '7d':
filterDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
break
case 'fixed':
return '2025-08-24 11:27:00'
case 'custom':
if (customTime) {
return customTime
}
// 如果自定义时间为空默认使用24小时
filterDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
default:
filterDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
} }
return filterDate.toISOString().slice(0, 19).replace('T', ' ') return parseInt(timeFilter) || 24 // 默认24小时
}, [timeFilter, customTime]) }, [timeFilter, customHours])
// 计算超额量
const calculateOverage = (assigned: number, count: number) => {
const overage = assigned - count;
return Math.max(0, overage);
}
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
try { try {
setError(null) setError(null)
setLoading(true) setLoading(true)
const timeCondition = getTimeCondition() const hours = getTimeHours()
const response = await fetch(`/api/stats?type=allocation_status&time=${encodeURIComponent(timeCondition)}`)
const response = await fetch(`/api/stats?type=allocation_status&hours=${hours}`)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
@@ -86,14 +63,16 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
assigned: validateNumber(item.assigned), assigned: validateNumber(item.assigned),
})) }))
setData(validatedData) const sortedData = validatedData.sort((a, b) => b.count - a.count)
setData(sortedData)
} catch (error) { } catch (error) {
console.error('Failed to fetch allocation status:', error) console.error('Failed to fetch allocation status:', error)
setError(error instanceof Error ? error.message : 'Unknown error') setError(error instanceof Error ? error.message : 'Unknown error')
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [getTimeCondition]) }, [getTimeHours])
useEffect(() => { useEffect(() => {
fetchData() fetchData()
@@ -102,10 +81,10 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
if (loading) return <LoadingCard title="节点分配状态" /> if (loading) return <LoadingCard title="节点分配状态" />
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} /> if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
const problematicCities = data.filter(item => item.count < item.count) const problematicCities = data.filter(item => item.assigned > item.count)
return ( return (
<div className="bg-white shadow rounded-lg p-6"> <div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden ">
<h2 className="text-lg font-semibold mb-4"></h2> <h2 className="text-lg font-semibold mb-4"></h2>
{/* 时间筛选器 */} {/* 时间筛选器 */}
@@ -116,23 +95,26 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
onChange={(e) => setTimeFilter(e.target.value)} onChange={(e) => setTimeFilter(e.target.value)}
className="border rounded p-2" className="border rounded p-2"
> >
<option value="1h">1</option> <option value="1">1</option>
<option value="6h">6</option> <option value="6">6</option>
<option value="12h">12</option> <option value="12">12</option>
<option value="24h">24</option> <option value="24">24</option>
<option value="7d">7</option> <option value="168">7</option>
<option value="custom"></option> <option value="custom"></option>
</select> </select>
{timeFilter === 'custom' && ( {timeFilter === 'custom' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="datetime-local" type="number"
value={customTime} min="1"
onChange={(e) => setCustomTime(e.target.value)} max="720"
className="border rounded p-2" value={customHours}
onChange={(e) => setCustomHours(e.target.value)}
placeholder="输入小时数"
className="border rounded p-2 w-24"
/> />
<small>格式: YYYY-MM-DDTHH:MM</small> <small> (1-720)</small>
</div> </div>
)} )}
@@ -140,45 +122,44 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
onClick={fetchData} onClick={fetchData}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
> >
</button> </button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4"> <div className='flex gap-6 overflow-hidden'>
<div className="bg-blue-50 p-4 rounded-lg"> <div className="flex w-full">
<div className="text-2xl font-bold text-blue-600">{formatNumber(data.length)}</div> <Table>
<div className="text-sm text-blue-800"></div> <TableHeader>
</div> <TableRow className="bg-gray-50">
<div className="bg-orange-50 p-4 rounded-lg"> <TableHead className="px-4 py-2 text-left"></TableHead>
<div className="text-2xl font-bold text-orange-600">{formatNumber(problematicCities.length)}</div> <TableHead className="px-4 py-2 text-left">IP量</TableHead>
<div className="text-sm text-orange-800"></div> <TableHead className="px-4 py-2 text-left">IP量</TableHead>
</div> <TableHead className="px-4 py-2 text-left"></TableHead>
</div> </TableRow>
</TableHeader>
{detailed && ( <TableBody>
<div className="overflow-x-auto">
<table className="min-w-full table-auto">
<thead>
<tr className="bg-gray-50">
<th className="px-4 py-2 text-left"></th>
<th className="px-4 py-2 text-left">IP量</th>
<th className="px-4 py-2 text-left">IP量</th>
</tr>
</thead>
<tbody>
{data.map((item, index) => { {data.map((item, index) => {
const overage = calculateOverage(item.assigned, item.count)
return ( return (
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}> <TableRow
<td className="px-4 py-2">{item.city}</td> key={index}
<td className="px-4 py-2">{formatNumber(item.count)}</td> className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
<td className="px-4 py-2">{formatNumber(item.assigned)}</td> >
</tr> <TableCell className="px-4 py-2">{item.city}</TableCell>
<TableCell className="px-4 py-2">{formatNumber(item.count)}</TableCell>
<TableCell className="px-4 py-2">{formatNumber(item.assigned)}</TableCell>
<TableCell className="px-4 py-2">
<span className={overage > 0 ? 'text-red-600 font-medium' : ''}>
{formatNumber(overage)}
</span>
</TableCell>
</TableRow>
) )
})} })}
</tbody> </TableBody>
</table> </Table>
</div> </div>
)} </div>
</div> </div>
) )
} }

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
interface CityNode { interface CityNode {
city: string city: string
@@ -32,7 +33,7 @@ export default function CityNodeStats() {
if (loading) { if (loading) {
return ( return (
<div className="bg-white rounded-lg p-6"> <div className="bg-white rounded-lg p-6 overflow-hidden">
<h2 className="text-lg font-semibold mb-4"></h2> <h2 className="text-lg font-semibold mb-4"></h2>
<div className="text-gray-600">...</div> <div className="text-gray-600">...</div>
</div> </div>
@@ -40,7 +41,7 @@ export default function CityNodeStats() {
} }
return ( return (
<div className="bg-white rounded-lg p-6"> <div className="flex flex-col bg-white rounded-lg p-6 overflow-hidden">
<div className="flex justify-between items-center mb-4"> <div className="flex justify-between items-center mb-4">
<h2 className="text-lg font-semibold"></h2> <h2 className="text-lg font-semibold"></h2>
<span className="text-sm text-gray-500"> <span className="text-sm text-gray-500">
@@ -48,35 +49,37 @@ export default function CityNodeStats() {
</span> </span>
</div> </div>
<div className="overflow-x-auto"> <div className="flex overflow-hidden ">
<table className="w-full"> <div className='flex w-full'>
<thead> <Table>
<tr className="border-b border-gray-200"> <TableHeader>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600"></th> <TableRow className="bg-gray-50">
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600"></th> <TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600">Hash</th> <TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600"></th> <TableHead className="px-4 py-2 text-left font-medium text-gray-600">Hash</TableHead>
<th className="px-4 py-2 text-left text-sm font-medium text-gray-600"></th> <TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
</tr> <TableHead className="px-4 py-2 text-left font-medium text-gray-600"></TableHead>
</thead> </TableRow>
<tbody> </TableHeader>
{data.map((item, index) => ( <TableBody>
<tr key={index} className="border-b border-gray-100 hover:bg-gray-50"> {data.map((item, index) => (
<td className="px-4 py-3 text-sm font-medium">{item.city}</td> <TableRow
<td className="px-4 py-3 text-sm"> key={index}
<span className="font-semibold text-gray-700">{item.count}</span> className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
</td> >
<td className="px-4 py-3 text-sm text-gray-500 font-mono">{item.hash}</td> <TableCell className="px-4 py-2">{item.city}</TableCell>
<td className="px-4 py-3 text-sm"> <TableCell className="px-4 py-2">{item.count}</TableCell>
<span className="bg-gray-100 px-2 py-1 rounded text-gray-700"> <TableCell className="px-4 py-2">{item.hash}</TableCell>
{item.label} <TableCell className="px-4 py-2">
</span> <span className="bg-gray-100 px-2 py-1 rounded text-gray-700">
</td> {item.label}</span>
<td className="px-4 py-3 text-sm font-semibold">{item.offset}</td> </TableCell>
</tr> <TableCell className="px-4 py-2">{item.offset}</TableCell>
))} </TableRow>
</tbody> ))}
</table> </TableBody>
</Table>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -2,14 +2,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { validateNumber } from '@/lib/formatters' import { validateNumber } from '@/lib/formatters'
import { import { Pagination } from '@/components/ui/pagination'
Pagination, import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
interface Edge { interface Edge {
id: number id: number
@@ -27,28 +21,28 @@ export default function Edge() {
const [data, setData] = useState<Edge[]>([]) const [data, setData] = useState<Edge[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [idThreshold, setIdThreshold] = useState(20)
const [limit, setLimit] = useState(100)
// 分页状态 // 分页状态
const [currentPage, setCurrentPage] = useState(1) const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState(10) const [itemsPerPage, setItemsPerPage] = useState(100) // 默认100条
const [totalItems, setTotalItems] = useState(0) const [totalItems, setTotalItems] = useState(0)
useEffect(() => { useEffect(() => {
fetchData() fetchData()
}, []) }, [currentPage, itemsPerPage]) // 监听页码和每页数量的变化
const fetchData = async (threshold: number = idThreshold, resultLimit: number = limit) => { const fetchData = async () => {
try { try {
setError(null) setError(null)
setLoading(true) setLoading(true)
const response = await fetch(`/api/stats?type=edge_nodes&threshold=${threshold}&limit=${resultLimit}`) // 计算偏移量
const offset = (currentPage - 1) * itemsPerPage
const response = await fetch(`/api/stats?type=edge_nodes&offset=${offset}&limit=${itemsPerPage}`)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const result = await response.json() const result = await response.json()
type ResultEdge = { type ResultEdge = {
id: number id: number
macaddr: string macaddr: string
@@ -61,21 +55,20 @@ export default function Edge() {
online: number online: number
} }
const validatedData = (result as ResultEdge[]).map((item) => ({ const validatedData = (result.data as ResultEdge[]).map((item) => ({
id: validateNumber(item.id), id: validateNumber(item.id),
macaddr: item.macaddr || '', macaddr: item.macaddr || '',
city: item.city || '', city: item.city || '',
public: item.public || '', public: item.public || '',
isp: item.isp || '', isp: item.isp || '',
single: item.single === 1 || item.single === true, single: item.single,
sole: item.sole === 1 || item.sole === true, sole: item.sole,
arch: validateNumber(item.arch), arch: validateNumber(item.arch),
online: validateNumber(item.online) online: validateNumber(item.online)
})) }))
setData(validatedData) setData(validatedData)
setTotalItems(validatedData.length) setTotalItems(result.totalCount || 0)
setCurrentPage(1) // 重置到第一页
} catch (error) { } catch (error) {
console.error('Failed to fetch edge nodes:', error) console.error('Failed to fetch edge nodes:', error)
setError(error instanceof Error ? error.message : '获取边缘节点数据失败') setError(error instanceof Error ? error.message : '获取边缘节点数据失败')
@@ -84,13 +77,66 @@ export default function Edge() {
} }
} }
const handleSubmit = (e: React.FormEvent) => { // 多IP节点格式化
e.preventDefault() const formatMultiIP = (value: number | boolean): string => {
fetchData(idThreshold, limit) if (typeof value === 'number') {
switch (value) {
case 1: return '是'
case 0: return '否'
case -1: return '未知'
default: return `未知 (${value})`
}
}
return value ? '是' : '否'
}
// 独享IP节点格式化
const formatExclusiveIP = (value: number | boolean): string => {
if (typeof value === 'number') {
return value === 1 ? '是' : '否'
}
return value ? '是' : '否'
}
// 多IP节点颜色
const getMultiIPColor = (value: number | boolean): string => {
if (typeof value === 'number') {
switch (value) {
case 1: return 'bg-red-100 text-red-800'
case 0: return 'bg-green-100 text-green-800'
case -1: return 'bg-gray-100 text-gray-800'
default: return 'bg-gray-100 text-gray-800'
}
}
return value ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
}
// 独享IP节点颜色
const getExclusiveIPColor = (value: number | boolean): string => {
if (typeof value === 'number') {
return value === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}
return value ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}
const formatArchType = (arch: number): string => {
switch (arch) {
case 0: return '一代'
case 1: return '二代'
case 2: return 'AMD64'
case 3: return 'x86'
default: return `未知 (${arch})`
}
} }
const formatBoolean = (value: boolean | number): string => { const getArchColor = (arch: number): string => {
return value ? '是' : '否' switch (arch) {
case 0: return 'bg-blue-100 text-blue-800'
case 1: return 'bg-green-100 text-green-800'
case 2: return 'bg-purple-100 text-purple-800'
case 3: return 'bg-orange-100 text-orange-800'
default: return 'bg-gray-100 text-gray-800'
}
} }
const formatOnlineTime = (seconds: number): string => { const formatOnlineTime = (seconds: number): string => {
@@ -100,42 +146,15 @@ export default function Edge() {
return `${Math.floor(seconds / 86400)}` return `${Math.floor(seconds / 86400)}`
} }
// 计算分页数据 // 处理页码变化
const indexOfLastItem = currentPage * itemsPerPage const handlePageChange = (page: number) => {
const indexOfFirstItem = indexOfLastItem - itemsPerPage setCurrentPage(page)
const currentItems = data.slice(indexOfFirstItem, indexOfLastItem) }
const totalPages = Math.ceil(totalItems / itemsPerPage)
// 生成页码按钮 // 处理每页显示数量变化
const renderPageNumbers = () => { const handleSizeChange = (size: number) => {
const pageNumbers = [] setItemsPerPage(size)
const maxVisiblePages = 5 setCurrentPage(1) // 重置到第一页
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2))
const endPage = Math.min(totalPages, startPage + maxVisiblePages - 1)
if (endPage - startPage + 1 < maxVisiblePages) {
startPage = Math.max(1, endPage - maxVisiblePages + 1)
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(
<PaginationItem key={i}>
<PaginationLink
href="#"
isActive={currentPage === i}
onClick={(e) => {
e.preventDefault()
setCurrentPage(i)
}}
>
{i}
</PaginationLink>
</PaginationItem>
)
}
return pageNumbers
} }
if (loading) return ( if (loading) return (
@@ -159,58 +178,7 @@ export default function Edge() {
) )
return ( return (
<div className="bg-white shadow rounded-lg p-6"> <div className="flex bg-white flex-col shadow overflow-hidden rounded-lg p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-800"></h2>
<button
onClick={() => fetchData()}
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 transition-colors"
>
</button>
</div>
{/* 查询表单 */}
<form onSubmit={handleSubmit} className="bg-gray-50 p-4 rounded-lg mb-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="threshold" className="block text-sm font-medium text-gray-700 mb-1">
ID阈值 (ID大于此值)
</label>
<input
id="threshold"
type="number"
value={idThreshold}
onChange={(e) => setIdThreshold(Number(e.target.value))}
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label htmlFor="limit" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
id="limit"
type="number"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
min="1"
max="1000"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex items-end">
<button
type="submit"
className="px-6 py-2 bg-green-600 text-white font-medium rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
>
</button>
</div>
</div>
</form>
{data.length === 0 ? ( {data.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">📋</div> <div className="text-gray-400 text-4xl mb-4">📋</div>
@@ -218,135 +186,69 @@ export default function Edge() {
</div> </div>
) : ( ) : (
<> <>
<div className="bg-blue-50 p-4 rounded-lg mb-6"> <div className='flex gap-6 overflow-hidden'>
<p className="text-blue-800"> <div className="flex-3 w-full overflow-y-auto">
<span className="font-bold">{totalItems}</span> <Table>
{idThreshold > 0 && ` (ID大于${idThreshold})`} <TableHeader>
</p> <TableRow className="bg-gray-50">
</div> <TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">MAC地址</TableHead>
<TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></TableHead>
{/* 每页显示数量选择器 */} <TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP</TableHead>
<div className="flex justify-between items-center mb-4"> <TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></TableHead>
<div className="flex items-center space-x-2"> <TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP节点</TableHead>
<span className="text-sm text-gray-700"></span> <TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP</TableHead>
<select <TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></TableHead>
value={itemsPerPage} <TableHead className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">线</TableHead>
onChange={(e) => { </TableRow>
setItemsPerPage(Number(e.target.value)) </TableHeader>
setCurrentPage(1) <TableBody>
}} {data.map((item, index) => (
className="border border-gray-300 rounded-md px-2 py-1 text-sm" <TableRow key={item.id} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
> <TableCell className="px-4 py-3 text-sm font-mono text-blue-600">{item.macaddr}</TableCell>
<option value="10">10</option> <TableCell className="px-4 py-3 text-sm text-gray-700">{item.city}</TableCell>
<option value="20">20</option> <TableCell className="px-4 py-3 text-sm font-mono text-green-600">{item.public}</TableCell>
<option value="50">50</option> <TableCell className="px-4 py-3 text-sm text-gray-700">
<option value="100">100</option> <span className={`px-2 py-1 rounded-full text-xs ${
</select> item.isp === '移动' ? 'bg-blue-100 text-blue-800' :
<span className="text-sm text-gray-700"></span> item.isp === '电信' ? 'bg-purple-100 text-purple-800' :
item.isp === '联通' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{item.isp}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMultiIPColor(item.single)}`}>
{formatMultiIP(item.single)}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExclusiveIPColor(item.sole)}`}>
{formatExclusiveIP(item.sole)}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getArchColor(item.arch)}`}>
{formatArchType(item.arch)}
</span>
</TableCell>
<TableCell className="px-4 py-3 text-sm text-gray-700">{formatOnlineTime(item.online)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
</div> </div>
<div className="overflow-x-auto rounded-lg shadow mb-4"> {/* 分页 */}
<table className="min-w-full table-auto border-collapse"> <Pagination
<thead> page={currentPage}
<tr className="bg-gray-100"> size={itemsPerPage}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">ID</th> total={totalItems}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">MAC地址</th> onPageChange={handlePageChange}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></th> onSizeChange={handleSizeChange}
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP</th> className="mt-4"
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></th> />
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP节点</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">IP</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">线</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{currentItems.map((item, index) => (
<tr
key={item.id}
className={`hover:bg-gray-50 transition-colors ${index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}`}
>
<td className="px-4 py-3 text-sm text-gray-900">{item.id}</td>
<td className="px-4 py-3 text-sm font-mono text-blue-600">{item.macaddr}</td>
<td className="px-4 py-3 text-sm text-gray-700">{item.city}</td>
<td className="px-4 py-3 text-sm font-mono text-green-600">{item.public}</td>
<td className="px-4 py-3 text-sm text-gray-700">
<span className={`px-2 py-1 rounded-full text-xs ${
item.isp === '移动' ? 'bg-blue-100 text-blue-800' :
item.isp === '电信' ? 'bg-purple-100 text-purple-800' :
item.isp === '联通' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{item.isp}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
item.single ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
}`}>
{formatBoolean(item.single)}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
item.sole ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
}`}>
{formatBoolean(item.sole)}
</span>
</td>
<td className="px-4 py-3 text-sm text-center">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
{item.arch}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700">
{formatOnlineTime(item.online)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页控件 */}
<div className="flex justify-between items-center mt-4">
<div className="text-sm text-gray-600">
{indexOfFirstItem + 1} {Math.min(indexOfLastItem, totalItems)} {totalItems}
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault()
if (currentPage > 1) setCurrentPage(currentPage - 1)
}}
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{renderPageNumbers()}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault()
if (currentPage < totalPages) setCurrentPage(currentPage + 1)
}}
className={currentPage === totalPages ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
<div className="text-sm text-gray-600">
: {new Date().toLocaleTimeString()}
</div>
</div>
</> </>
)} )}
</div> </div>

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState, Suspense } from 'react'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { Pagination } from '@/components/ui/pagination'
interface GatewayConfig { interface GatewayConfig {
id: number
city: string city: string
edge: string edge: string
user: string user: string
@@ -13,77 +14,83 @@ interface GatewayConfig {
isonline: number isonline: number
} }
export default function GatewayConfig() { interface ApiResponse {
data: GatewayConfig[]
totalCount: number
currentPage: number
totalPages: number
}
function GatewayConfigContent() {
const [data, setData] = useState<GatewayConfig[]>([]) const [data, setData] = useState<GatewayConfig[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [macAddress, setMacAddress] = useState('') const [macAddress, setMacAddress] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const searchParams = useSearchParams() const searchParams = useSearchParams()
// 分页状态
const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState(100)
const [totalItems, setTotalItems] = useState(0)
// 监听URL的mac参数变化同步到输入框并触发查询 // 判断是否为MAC地址查询用于控制分页显示
const isMacQuery = !!macAddress
// 监听URL的mac参数变化
useEffect(() => { useEffect(() => {
const urlMac = searchParams.get('mac') const urlMac = searchParams.get('mac')
if (urlMac) { if (urlMac) {
setMacAddress(urlMac) setMacAddress(urlMac)
fetchData(urlMac) setCurrentPage(1) // 重置到第一页
fetchData(urlMac, 1, itemsPerPage)
} else { } else {
// 如果没有mac参数显示空状态或默认查询 setMacAddress('')
setData([]) setCurrentPage(1) // 重置到第一页
setSuccess('请输入MAC地址查询网关配置信息') fetchData('', 1, itemsPerPage)
} }
}, [searchParams]) }, [searchParams])
const fetchData = async (mac: string) => { const fetchData = async (mac: string, page: number = 1, limit: number = itemsPerPage) => {
if (!mac.trim()) {
setError('请输入MAC地址')
setSuccess('')
setData([])
return
}
setLoading(true) setLoading(true)
setError('') setError('')
setSuccess('')
try { try {
const response = await fetch(`/api/stats?type=gateway_config&mac=${encodeURIComponent(mac)}`) // 计算偏移量
const result = await response.json() const offset = (page - 1) * limit
if (!response.ok) { // 构建API URL
throw new Error(result.error || '查询失败') let apiUrl = `/api/stats?type=gateway_config&offset=${offset}&limit=${limit}`
if (mac.trim()) {
apiUrl += `&mac=${encodeURIComponent(mac)}`
} }
const response = await fetch(apiUrl)
if (!response.ok) {
throw new Error(`HTTP错误! 状态: ${response.status}`)
}
const result: ApiResponse = await response.json()
// 检查返回的数据是否有效 // 检查返回的数据是否有效
if (!result || result.length === 0) { if (!result.data || result.data.length === 0) {
setError(`未找到MAC地址为 ${mac} 的网关配置信息`) if (mac.trim()) {
setError(`未找到MAC地址为 ${mac} 的网关配置信息`)
} else {
setError('未找到任何网关配置信息')
}
setData([]) setData([])
setTotalItems(0)
return return
} }
const validatedData = result.map((item: { setData(result.data)
city: string
edge: string
user: string
public: string
inner_ip: string
ischange: number
isonline: number
}) => ({
city: item.city,
edge: item.edge,
user: item.user,
public: item.public,
inner_ip: item.inner_ip,
ischange: item.ischange,
isonline: item.isonline,
}))
setData(validatedData) setTotalItems(result.totalCount || 0)
setSuccess(`成功查询到 ${validatedData.length} 条网关配置信息`)
} catch (error) { } catch (error) {
console.error('Failed to fetch gateway config:', error) console.error('获取网关配置失败:', error)
setError(error instanceof Error ? error.message : '获取网关配置失败') setError(error instanceof Error ? error.message : '获取网关配置失败')
setData([]) setData([])
setTotalItems(0)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -91,54 +98,60 @@ export default function GatewayConfig() {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (macAddress.trim()) { setCurrentPage(1) // 重置到第一页
fetchData(macAddress) fetchData(macAddress, 1, itemsPerPage)
} }
// 处理页码变化
const handlePageChange = (page: number) => {
setCurrentPage(page)
fetchData(macAddress, page, itemsPerPage)
}
// 处理每页显示数量变化
const handleSizeChange = (size: number) => {
setItemsPerPage(size)
setCurrentPage(1)
fetchData(macAddress, 1, size)
} }
const getStatusBadge = (value: number, trueText: string = '是', falseText: string = '否') => { const getStatusBadge = (value: number, trueText: string = '是', falseText: string = '否') => {
// 0是正常1是更新正常绿+ 更新(红)
return ( return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${ <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
value === 1 value === 0 ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
? 'bg-green-100 text-green-800' {value === 0 ? trueText : falseText}
: 'bg-red-100 text-red-800'
}`}>
{value === 1 ? trueText : falseText}
</span> </span>
) )
} }
const getOnlineStatus = (isonline: number) => { const getOnlineStatus = (isonline: number) => {
// 0是空闲1是在用在用+ 空闲(绿)
return ( return (
<div className="flex items-center"> <div className="flex items-center">
<div className={`w-2 h-2 rounded-full mr-2 ${ <div className={`${isonline === 0 ? 'bg-green-500' : 'bg-red-500'}`} />
isonline === 1 ? 'bg-green-500' : 'bg-red-500' {getStatusBadge(isonline, '空闲', '在用')}
}`} />
{getStatusBadge(isonline, '在线', '离线')}
</div> </div>
) )
} }
return ( return (
<div className="bg-white shadow rounded-lg p-6"> <div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden">
<div className="flex justify-between items-start mb-6"> <div className="flex justify-between items-start mb-6">
<div> <div>
<h2 className="text-xl font-semibold text-gray-800"></h2> <h2 className="text-xl font-semibold text-gray-800"></h2>
<p className="text-sm text-gray-600 mt-1"></p> <p className="text-sm text-gray-600 mt-1"></p>
</div> </div>
<div className="text-sm text-gray-500">
: {new Date().toLocaleTimeString()}
</div>
</div> </div>
{/* 查询表单 */} {/* 查询表单 */}
<form onSubmit={handleSubmit} className="mb-6"> <form onSubmit={handleSubmit} className="mb-6">
<div className="flex items-center"> <div className="flex items-center gap-2">
<input <input
type="text" type="text"
value={macAddress} value={macAddress}
onChange={(e) => setMacAddress(e.target.value)} onChange={(e) => setMacAddress(e.target.value)}
placeholder="输入MAC地址" placeholder="输入MAC地址查询"
className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="px-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> />
<button <button
@@ -159,17 +172,6 @@ export default function GatewayConfig() {
</div> </div>
</div> </div>
)} )}
{success && !error && (
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-md">
<div className="flex items-center text-green-800">
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
{success}
</div>
</div>
)}
</form> </form>
{loading ? ( {loading ? (
@@ -178,105 +180,103 @@ export default function GatewayConfig() {
<p className="text-gray-600">...</p> <p className="text-gray-600">...</p>
</div> </div>
) : data.length > 0 ? ( ) : data.length > 0 ? (
<> <>
{/* 统计卡片 */} <div className='flex gap-6 overflow-hidden'>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <div className="flex-3 w-full flex">
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100"> <Table>
<div className="text-2xl font-bold text-blue-600">{data.length}</div> <TableHeader>
<div className="text-sm text-blue-800"></div> <TableRow className="bg-gray-50">
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">MAC地址</TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP地址</TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">线</TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
<TableHead className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item, index) => (
<TableRow key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<TableCell>
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
{item.city}
</span>
</TableCell>
<TableCell>
<div className="font-mono text-sm text-blue-600 font-medium">{item.edge}</div>
</TableCell>
<TableCell>
<div className="font-mono text-sm text-green-600">{item.public}</div>
</TableCell>
<TableCell className="text-sm text-gray-900">{item.user}</TableCell>
<TableCell>
<div className="font-mono text-sm text-purple-600">{item.inner_ip}</div>
</TableCell>
<TableCell>
{getStatusBadge(item.ischange, '正常', '更新')}
</TableCell>
<TableCell>
{getOnlineStatus(item.isonline)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
<div className="bg-green-50 p-4 rounded-lg border border-green-100"> <div className="flex flex-1 flex-col gap-4 mb-6">
<div className="text-2xl font-bold text-green-600"> <div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
{data.filter(item => item.isonline === 1).length} <div className="text-2xl font-bold text-blue-600">{totalItems}</div>
<div className="text-sm text-blue-800"></div>
</div> </div>
<div className="text-sm text-green-800">线</div> <div className="bg-green-50 p-4 rounded-lg border border-green-100">
</div> <div className="text-2xl font-bold text-green-600">
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100"> {data.filter(item => item.isonline === 1).length}
<div className="text-2xl font-bold text-orange-600"> </div>
{data.filter(item => item.ischange === 1).length} <div className="text-sm text-green-800"></div>
</div> </div>
<div className="text-sm text-orange-800"></div> <div className="bg-orange-50 p-4 rounded-lg border border-orange-100">
</div> <div className="text-2xl font-bold text-orange-600">
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100"> {data.filter(item => item.ischange === 1).length}
<div className="text-2xl font-bold text-purple-600"> </div>
{new Set(data.map(item => item.city)).size} <div className="text-sm text-orange-800"></div>
</div> </div>
<div className="text-sm text-purple-800"></div>
</div> </div>
</div> </div>
{/* 详细表格 */} {/* 分页组件 - 仅在非MAC查询时显示 */}
<div className="overflow-hidden rounded-lg shadow-sm border border-gray-200"> {!isMacQuery && (
<table className="min-w-full divide-y divide-gray-200"> <Pagination
<thead className="bg-gray-50"> page={currentPage}
<tr> size={itemsPerPage}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">MAC地址</th> total={totalItems}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th> onPageChange={handlePageChange}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th> onSizeChange={handleSizeChange}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP地址</th> className="mt-4"
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th> />
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"></th> )}
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">线</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{data.map((item, index) => (
<tr key={index} className="hover:bg-gray-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-mono text-sm text-blue-600 font-medium">
{item.edge}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 py-1 bg-gray-100 text-gray-700 rounded-full text-xs">
{item.city}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.user}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-mono text-sm text-green-600">
{item.public}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="font-mono text-sm text-purple-600">
{item.inner_ip}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(item.ischange, '已更新', '未更新')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getOnlineStatus(item.isonline)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页信息 */}
<div className="mt-4 flex justify-between items-center text-sm text-gray-600">
<span> 1 {data.length} {data.length} </span>
<button
onClick={() => fetchData(macAddress)}
className="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
>
</button>
</div>
</> </>
) : ( ) : (
<div className="text-center py-12"> <div className="text-center py-12">
<div className="text-gray-400 text-4xl mb-4">🔍</div> <div className="text-gray-400 text-4xl mb-4">📋</div>
<p className="text-gray-600">MAC地址查询网关配置信息</p> <p className="text-gray-600"></p>
<p className="text-sm text-gray-500 mt-2">
MAC地址的网关配置
</p>
</div> </div>
)} )}
</div> </div>
) )
}
export default function GatewayConfig() {
return (
<Suspense fallback={
<div className="bg-white shadow rounded-lg p-6">
<div className="text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
}>
<GatewayConfigContent />
</Suspense>
)
} }

View File

@@ -2,6 +2,12 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { Table, TableHeader, TableBody, TableHead, TableRow, TableCell } from '@/components/ui/table'
import { Form, FormField } from '@/components/ui/form'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { z } from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
interface GatewayInfo { interface GatewayInfo {
macaddr: string macaddr: string
@@ -10,29 +16,74 @@ interface GatewayInfo {
enable: number enable: number
} }
const filterSchema = z.object({
status: z.string(),
})
type FilterSchema = z.infer<typeof filterSchema>
// IP地址排序函数
const sortByIpAddress = (a: string, b: string): number => {
const ipToNumber = (ip: string): number => {
const parts = ip.split('.').map(part => parseInt(part, 10));
return (parts[0] << 24) + (parts[1] << 16) + (parts[2] << 8) + parts[3];
};
return ipToNumber(a) - ipToNumber(b);
}
export default function Gatewayinfo() { export default function Gatewayinfo() {
const [data, setData] = useState<GatewayInfo[]>([]) const [data, setData] = useState<GatewayInfo[]>([])
const [filteredData, setFilteredData] = useState<GatewayInfo[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const router = useRouter() const router = useRouter()
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
status: '1',
},
})
const { watch } = form
const statusFilter = watch('status')
useEffect(() => { useEffect(() => {
fetchData() fetchData()
}, []) }, [])
useEffect(() => {
if (!data.length) return
if (statusFilter === 'all') {
setFilteredData(data)
} else {
const enableValue = parseInt(statusFilter)
// 添加 NaN 检查
if (isNaN(enableValue)) {
setFilteredData(data)
} else {
setFilteredData(data.filter(item => item.enable === enableValue))
}
}
}, [data, statusFilter])
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true) setLoading(true)
setError('') setError('')
const response = await fetch('/api/stats?type=gateway_info') const response = await fetch('/api/stats?type=gateway_info')
if (!response.ok) { if (!response.ok) {
throw new Error('获取网关信息失败') throw new Error('获取网关信息失败')
} }
const result = await response.json() const result = await response.json()
// const sortedData = result.sort(( a, b) => Number(a.inner_ip) - Number(b.inner_ip))
setData(result) const sortedData = result.sort((a: GatewayInfo, b: GatewayInfo) =>
sortByIpAddress(a.inner_ip, b.inner_ip)
)
setData(sortedData)
setFilteredData(sortedData) // 初始化时设置filteredData
} catch (error) { } catch (error) {
console.error('Failed to fetch gateway info:', error) console.error('Failed to fetch gateway info:', error)
setError(error instanceof Error ? error.message : '获取网关信息失败') setError(error instanceof Error ? error.message : '获取网关信息失败')
@@ -53,7 +104,7 @@ export default function Gatewayinfo() {
if (loading) { if (loading) {
return ( return (
<div className="bg-white shadow rounded-lg p-6"> <div className="bg-white shadow rounded-lg p-6 overflow-hidden">
<h2 className="text-lg font-semibold mb-4"></h2> <h2 className="text-lg font-semibold mb-4"></h2>
<div className="text-center py-8">...</div> <div className="text-center py-8">...</div>
</div> </div>
@@ -67,71 +118,102 @@ export default function Gatewayinfo() {
<div className="text-center py-8 text-red-600">{error}</div> <div className="text-center py-8 text-red-600">{error}</div>
</div> </div>
) )
} }
return ( return (
<div className="bg-white shadow rounded-lg p-6"> <div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden">
<h2 className="text-lg font-semibold mb-4"></h2> <div className='flex gap-6'>
<div className='flex flex-3 justify-between '>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"> <span className="text-lg pt-2 font-semibold mb-4"></span>
<div className="bg-blue-50 p-4 rounded-lg"> <Form {...form}>
<div className="text-2xl font-bold text-blue-600">{data.length}</div> <form className="flex items-center gap-4">
<div className="text-sm text-blue-800"></div> <FormField
</div> control={form.control}
<div className="bg-green-50 p-4 rounded-lg"> name="status"
<div className="text-2xl font-bold text-green-600"> render={({ field }) => (
{data.filter(item => item.enable === 1).length} <div className="flex items-center">
</div> <span className="text-sm mr-2">:</span>
<div className="text-sm text-green-800"></div> <Select
</div> value={field.value}
<div className="bg-red-50 p-4 rounded-lg"> onValueChange={field.onChange}
<div className="text-2xl font-bold text-red-600"> defaultValue="1"
{data.filter(item => item.enable === 0).length} >
</div> <SelectTrigger className="h-9 w-36">
<div className="text-sm text-red-800"></div> <SelectValue placeholder="选择状态" />
</div> </SelectTrigger>
<div className="bg-purple-50 p-4 rounded-lg"> <SelectContent>
<div className="text-2xl font-bold text-purple-600"> <SelectItem value="all"></SelectItem>
{new Set(data.map(item => item.setid)).size} <SelectItem value="1"></SelectItem>
</div> <SelectItem value="0"></SelectItem>
<div className="text-sm text-purple-800"></div> </SelectContent>
</Select>
</div>
)}
/>
</form>
</Form>
</div> </div>
<div className='flex flex-1'></div>
</div> </div>
<div className='flex gap-6 overflow-hidden'>
<div className="flex-3 w-full flex">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="px-4 py-2 text-left">MAC地址</TableHead>
<TableHead className="px-4 py-2 text-left">IP</TableHead>
<TableHead className="px-4 py-2 text-left"></TableHead>
<TableHead className="px-4 py-2 text-left"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredData.map((item, index) => (
<TableRow
key={index}
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
>
<TableCell className="px-4 py-2">
<button
onClick={() => {
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`);
}}
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
>
{item.macaddr}
</button>
</TableCell>
<TableCell className="px-4 py-2">{item.inner_ip}</TableCell>
<TableCell className="px-4 py-2">{item.setid}</TableCell>
<TableCell className="px-4 py-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}>
{getStatusText(item.enable)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="overflow-x-auto"> <div className="flex flex-1 flex-col gap-4 mb-6">
<table className="min-w-full table-auto"> <div className="bg-blue-50 p-4 rounded-lg">
<thead> <div className="text-2xl font-bold text-blue-600">{data.length}</div>
<tr className="bg-gray-50"> <div className="text-sm text-blue-800"></div>
<th className="px-4 py-2 text-left">MAC地址</th> </div>
<th className="px-4 py-2 text-left">IP</th> <div className="bg-green-50 p-4 rounded-lg">
<th className="px-4 py-2 text-left"></th> <div className="text-2xl font-bold text-green-600">
<th className="px-4 py-2 text-left"></th> {data.filter(item => item.enable === 1).length}
</tr> </div>
</thead> <div className="text-sm text-green-800"></div>
<tbody> </div>
{data.map((item, index) => ( <div className="bg-red-50 p-4 rounded-lg">
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}> <div className="text-2xl font-bold text-red-600">
<td className="px-4 py-2"> {data.filter(item => item.enable === 0).length}
<button </div>
onClick={() => { <div className="text-sm text-red-800"></div>
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`); </div>
}} </div>
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
>
{item.macaddr}
</button>
</td>
<td className="px-4 py-2">{item.inner_ip}</td>
<td className="px-4 py-2">{item.setid}</td>
<td className="px-4 py-2">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}>
{getStatusText(item.enable)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</div> </div>
) )

View File

@@ -0,0 +1,326 @@
'use client'
import { useState, useEffect } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { User, Lock, Search, Trash2, Plus, X } from 'lucide-react'
import { toast, Toaster } from 'sonner'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"
// 用户类型定义
interface UserData {
id: string
account: string
createdAt: string
}
const formSchema = z.object({
account: z.string().min(3, '账号至少需要3个字符'),
password: z.string().min(6, '密码至少需要6个字符'),
confirmPassword: z.string().min(6, '密码至少需要6个字符'),
}).refine((data) => data.password === data.confirmPassword, {
message: "密码不匹配",
path: ["confirmPassword"],
})
export default function Settings() {
const [loading, setLoading] = useState(false)
const [users, setUsers] = useState<UserData[]>([])
const [searchTerm, setSearchTerm] = useState('')
const [isCreateMode, setIsCreateMode] = useState(false)
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
account: '',
password: '',
confirmPassword: '',
},
})
// 获取用户列表
const fetchUsers = async () => {
try {
const response = await fetch('/api/users')
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || '获取用户列表失败')
}
if (data.success) {
setUsers(data.users)
}
} catch (error) {
toast.error("获取用户列表失败", {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
})
}
}
// 初始加载用户列表
useEffect(() => {
fetchUsers()
}, [])
// 创建用户
async function onSubmit(values: z.infer<typeof formSchema>) {
setLoading(true)
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
account: values.account,
password: values.password,
}),
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || '创建用户失败')
}
if (data.success) {
toast.success("用户创建成功", {
description: "新账户已成功添加",
})
form.reset()
setIsCreateMode(false)
fetchUsers() // 刷新用户列表
}
} catch (error) {
toast.error("创建用户失败", {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
})
} finally {
setLoading(false)
}
}
// 删除用户
const handleDeleteUser = async (userId: number) => {
if (!confirm('确定要删除这个用户吗?此操作不可恢复。')) {
return
}
try {
// 使用查询参数传递ID
const response = await fetch(`/api/users?id=${userId}`, {
method: 'DELETE',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || '删除用户失败')
}
if (data.success) {
toast.success("用户删除成功", {
description: "用户账户已删除",
})
fetchUsers() // 刷新用户列表
}
} catch (error) {
toast.error("删除用户失败", {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
})
}
}
// 过滤用户列表
const filteredUsers = users.filter(user =>
user.account.toLowerCase().includes(searchTerm.toLowerCase())
)
return (
<div className="bg-white p-4 md:p-8">
<div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"></h1>
<Button onClick={() => setIsCreateMode(!isCreateMode)}>
{isCreateMode ? (
<>
<X className="mr-2 h-4 w-4" />
</>
) : (
<>
<Plus className="mr-2 h-4 w-4" />
</>
)}
</Button>
</div>
{isCreateMode && (
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-xl"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<User className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="请输入需要添加的账号"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="password"
placeholder="请输入密码"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<div className="relative">
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="password"
placeholder="请再次输入密码"
className="pl-8"
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-2">
<Button
type="submit"
disabled={loading}
>
{loading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
...
</div>
) : (
'创建用户'
)}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setIsCreateMode(false)}
>
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
)}
{/* 用户列表直接显示在页面上 */}
<div className="space-y-4">
<div>
<h2 className="text-2xl font-semibold"></h2>
<p className="text-muted-foreground"></p>
</div>
<div className="relative max-w-sm mt-4">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索用户..."
className="pl-8"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-4">
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.account}</TableCell>
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-5 border-0 hover:bg-transparent"
onClick={() => handleDeleteUser(Number(user.id))}
><Trash2 className="h-4 w-4" /></Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</div>
<Toaster richColors />
</div>
)
}

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import { useState, useEffect } from 'react' import { useState, useEffect, Suspense } from 'react'
import { useRouter } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import Gatewayinfo from './components/gatewayinfo' import Gatewayinfo from './components/gatewayinfo'
import GatewayConfig from './components/gatewayConfig' import GatewayConfig from './components/gatewayConfig'
import CityNodeStats from './components/cityNodeStats' import CityNodeStats from './components/cityNodeStats'
import AllocationStatus from './components/allocationStatus' import AllocationStatus from './components/allocationStatus'
import Settings from './components/settings'
import Edge from './components/edge' import Edge from './components/edge'
import { LogOut } from 'lucide-react' import { LogOut } from 'lucide-react'
@@ -14,24 +15,23 @@ const tabs = [
{ id: 'gateway', label: '网关配置' }, { id: 'gateway', label: '网关配置' },
{ id: 'city', label: '城市信息' }, { id: 'city', label: '城市信息' },
{ id: 'allocation', label: '分配状态' }, { id: 'allocation', label: '分配状态' },
{ id: 'edge', label: '节点信息' } { id: 'edge', label: '节点信息' },
{ id: 'setting', label: '设置'}
] ]
export default function Dashboard() { function DashboardContent() {
const [activeTab, setActiveTab] = useState('gatewayInfo') const [activeTab, setActiveTab] = useState('gatewayInfo')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams()
// URL 中获取 tab 参数 // 监听URL参数变化
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { const urlTab = searchParams.get('tab')
const urlParams = new URLSearchParams(window.location.search) if (urlTab && tabs.some(tab => tab.id === urlTab)) {
const urlTab = urlParams.get('tab') setActiveTab(urlTab)
if (urlTab && tabs.some(tab => tab.id === urlTab)) {
setActiveTab(urlTab)
}
} }
}, []) }, [searchParams])
// 退出登录 // 退出登录
const handleLogout = async () => { const handleLogout = async () => {
@@ -58,12 +58,14 @@ export default function Dashboard() {
const handleTabClick = (tabId: string) => { const handleTabClick = (tabId: string) => {
setActiveTab(tabId) setActiveTab(tabId)
// 更新 URL 参数 // 更新 URL 参数
router.push(`/dashboard?tab=${tabId}`) const params = new URLSearchParams()
params.set('tab', tabId)
router.push(`/dashboard?${params.toString()}`)
} }
return ( return (
<div className="min-h-screen bg-gray-100"> <div className=" bg-gray-100 w-screen h-screen flex flex-col">
<nav className="bg-white shadow-sm"> <nav className="bg-white flex-none h-16 shadow-sm">
<div className="px-4 sm:px-6 lg:px-8"> <div className="px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16 items-center"> <div className="flex justify-between h-16 items-center">
<div className="flex items-center"> <div className="flex items-center">
@@ -83,14 +85,14 @@ export default function Dashboard() {
</div> </div>
</nav> </nav>
<div className="px-4 sm:px-6 lg:px-8 py-8"> <div className="flex flex-3 overflow-hidden px-4 sm:px-6 lg:px-8 py-8">
<div className="border-b border-gray-200 mb-6"> <div className="border-b border-gray-200 mb-6">
<nav className="-mb-px flex space-x-8"> <nav className="flex flex-col w-64 -mb-px space-x-8">
{tabs.map((tab) => ( {tabs.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => handleTabClick(tab.id)} onClick={() => handleTabClick(tab.id)}
className={`py-2 px-1 border-b-2 font-medium text-sm ${ className={`py-2 px-1 h-12 border-b-2 font-medium text-sm ${
activeTab === tab.id activeTab === tab.id
? 'border-blue-500 text-blue-600' ? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
@@ -102,14 +104,30 @@ export default function Dashboard() {
</nav> </nav>
</div> </div>
<div className="grid grid-cols-1 gap-6"> <div className="grid grid-cols-1 gap-6 flex-auto">
{activeTab === 'gatewayInfo' && <Gatewayinfo />} {activeTab === 'gatewayInfo' && <Gatewayinfo />}
{activeTab === 'gateway' && <GatewayConfig />} {activeTab === 'gateway' && <GatewayConfig />}
{activeTab === 'city' && <CityNodeStats />} {activeTab === 'city' && <CityNodeStats />}
{activeTab === 'allocation' && <AllocationStatus detailed />} {activeTab === 'allocation' && <AllocationStatus detailed />}
{activeTab === 'edge' && <Edge />} {activeTab === 'edge' && <Edge />}
{activeTab === 'setting' && <Settings/>}
</div> </div>
</div> </div>
</div> </div>
) )
}
export default function Dashboard() {
return (
<Suspense fallback={
<div className=" bg-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
}>
<DashboardContent />
</Suspense>
)
} }

View File

@@ -1,18 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { SessionProvider } from "next-auth/react";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "Create Next App",
@@ -25,14 +13,12 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html lang="en"> <html lang="zh-Hans">
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`antialiased`}
> >
<SessionProvider>
{children} {children}
<Toaster richColors /> <Toaster richColors />
</SessionProvider>
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -1,20 +1,188 @@
import * as React from "react" 'use client'
import * as React from 'react'
import {useState, useEffect} from 'react'
import { import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
MoreHorizontalIcon, MoreHorizontalIcon,
} from "lucide-react" } from 'lucide-react'
import { cn } from "@/lib/utils" import {cn} from '@/lib/utils'
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) { import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from './select'
export interface PaginationProps {
page: number
size: number
total: number
sizeOptions?: number[]
onPageChange?: (page: number) => void
onSizeChange?: (size: number) => void
className?: string
}
function Pagination({
page,
size,
total,
sizeOptions = [10, 20, 50, 100],
onPageChange,
onSizeChange,
className,
}: PaginationProps) {
const [currentPage, setCurrentPage] = useState(page)
const totalPages = Math.ceil(total / size)
// 同步外部 page 变化
useEffect(() => {
setCurrentPage(page)
}, [page])
// 分页器逻辑
const generatePaginationItems = () => {
// 最多显示7个页码其余用省略号
const SIBLINGS = 1 // 当前页左右各显示的页码数
const DOTS = -1 // 省略号标记
if (totalPages <= 7) {
// 总页数少于7全部显示
return Array.from({length: totalPages}, (_, i) => i + 1)
}
// 是否需要显示左边的省略号
const showLeftDots = currentPage > 2 + SIBLINGS
// 是否需要显示右边的省略号
const showRightDots = currentPage < totalPages - (2 + SIBLINGS)
if (showLeftDots && showRightDots) {
// 两边都有省略号
const leftSiblingIndex = Math.max(currentPage - SIBLINGS, 1)
const rightSiblingIndex = Math.min(currentPage + SIBLINGS, totalPages)
return [1, DOTS, ...Array.from(
{length: rightSiblingIndex - leftSiblingIndex + 1},
(_, i) => leftSiblingIndex + i,
), DOTS, totalPages]
}
if (!showLeftDots && showRightDots) {
// 只有右边有省略号
return [...Array.from({length: 3 + SIBLINGS * 2}, (_, i) => i + 1), DOTS, totalPages]
}
if (showLeftDots && !showRightDots) {
// 只有左边有省略号
return [1, DOTS, ...Array.from(
{length: 3 + SIBLINGS * 2},
(_, i) => totalPages - (3 + SIBLINGS * 2) + i + 1,
)]
}
return []
}
const handlePageChange = (newPage: number) => {
if (newPage < 1 || newPage > totalPages || newPage === currentPage) {
return
}
setCurrentPage(newPage)
onPageChange?.(newPage)
}
const handlePageSizeChange = (newSize: string) => {
const parsedSize = parseInt(newSize, 10)
if (onSizeChange) {
onSizeChange(parsedSize)
}
}
const paginationItems = generatePaginationItems()
return (
<div className={`flex flex-wrap items-center justify-end gap-4 ${className || ''}`}>
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
{' '}
{total}
{' '}
<Select
value={size.toString()}
onValueChange={handlePageSizeChange}
>
<SelectTrigger className="h-8 w-20">
<SelectValue/>
</SelectTrigger>
<SelectContent>
{sizeOptions.map(option => (
<SelectItem key={option} value={option.toString()}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<PaginationLayout>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => handlePageChange(currentPage - 1)}
className={currentPage === 1 ? 'opacity-50 pointer-events-none' : ''}
/>
</PaginationItem>
{paginationItems.map((pageNum, index) => {
if (pageNum === -1) {
return (
<PaginationItem key={`dots-${index}`}>
<PaginationEllipsis/>
</PaginationItem>
)
}
return (
<PaginationItem key={pageNum}>
<PaginationLink
isActive={pageNum === currentPage}
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
onClick={() => handlePageChange(currentPage + 1)}
className={currentPage === totalPages ? 'opacity-50 pointer-events-none' : ''}
/>
</PaginationItem>
</PaginationContent>
</PaginationLayout>
</div>
)
}
function PaginationLayout({className, ...props}: React.ComponentProps<'nav'>) {
return ( return (
<nav <nav
role="navigation" role="navigation"
aria-label="pagination" aria-label="pagination"
data-slot="pagination" data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)} className={cn('flex-none', className)}
{...props} {...props}
/> />
) )
@@ -23,42 +191,39 @@ function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
function PaginationContent({ function PaginationContent({
className, className,
...props ...props
}: React.ComponentProps<"ul">) { }: React.ComponentProps<'ul'>) {
return ( return (
<ul <ul
data-slot="pagination-content" data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)} className={cn('flex flex-row items-center gap-1', className)}
{...props} {...props}
/> />
) )
} }
function PaginationItem({ ...props }: React.ComponentProps<"li">) { function PaginationItem({...props}: React.ComponentProps<'li'>) {
return <li data-slot="pagination-item" {...props} /> return <li data-slot="pagination-item" {...props}/>
} }
type PaginationLinkProps = { type PaginationLinkProps = {
isActive?: boolean isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> & } & React.ComponentProps<'a'>
React.ComponentProps<"a">
function PaginationLink({ function PaginationLink({
className, className,
isActive, isActive,
size = "icon",
...props ...props
}: PaginationLinkProps) { }: PaginationLinkProps) {
return ( return (
<a <a
aria-current={isActive ? "page" : undefined} aria-current={isActive ? 'page' : undefined}
data-slot="pagination-link" data-slot="pagination-link"
data-active={isActive} data-active={isActive}
className={cn( className={cn(
buttonVariants({ 'inline-flex items-center justify-center text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 w-9 rounded-md border border-input hover:bg-secondary hover:text-secondary-foreground',
variant: isActive ? "outline" : "ghost", `bg-card`,
size, isActive && 'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground',
}), className,
className
)} )}
{...props} {...props}
/> />
@@ -72,12 +237,10 @@ function PaginationPrevious({
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to previous page" aria-label="Go to previous page"
size="default" className={cn('gap-1 px-2.5 sm:pl-2.5', className)}
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props} {...props}
> >
<ChevronLeftIcon /> <ChevronLeftIcon/>
<span className="hidden sm:block">Previous</span>
</PaginationLink> </PaginationLink>
) )
} }
@@ -89,12 +252,10 @@ function PaginationNext({
return ( return (
<PaginationLink <PaginationLink
aria-label="Go to next page" aria-label="Go to next page"
size="default" className={cn('gap-1 px-2.5 sm:pr-2.5', className)}
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props} {...props}
> >
<span className="hidden sm:block">Next</span> <ChevronRightIcon/>
<ChevronRightIcon />
</PaginationLink> </PaginationLink>
) )
} }
@@ -102,15 +263,15 @@ function PaginationNext({
function PaginationEllipsis({ function PaginationEllipsis({
className, className,
...props ...props
}: React.ComponentProps<"span">) { }: React.ComponentProps<'span'>) {
return ( return (
<span <span
aria-hidden aria-hidden
data-slot="pagination-ellipsis" data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)} className={cn('flex size-9 items-center justify-center', className)}
{...props} {...props}
> >
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4"/>
<span className="sr-only">More pages</span> <span className="sr-only">More pages</span>
</span> </span>
) )
@@ -118,6 +279,7 @@ function PaginationEllipsis({
export { export {
Pagination, Pagination,
PaginationLayout,
PaginationContent, PaginationContent,
PaginationLink, PaginationLink,
PaginationItem, PaginationItem,

View File

@@ -12,7 +12,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
> >
<table <table
data-slot="table" data-slot="table"
className={cn("w-full caption-bottom text-sm", className)} className={cn("w-full caption-bottom text-sm ", className)}
{...props} {...props}
/> />
</div> </div>
@@ -23,7 +23,7 @@ function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return ( return (
<thead <thead
data-slot="table-header" data-slot="table-header"
className={cn("[&_tr]:border-b", className)} className={cn("[&_tr]:border-b sticky top-0", className)}
{...props} {...props}
/> />
) )
@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
<tr <tr
data-slot="table-row" data-slot="table-row"
className={cn( className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors", "hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors h-10",
className className
)} )}
{...props} {...props}
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td <td
data-slot="table-cell" data-slot="table-cell"
className={cn( className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-2 h-10 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className
)} )}
{...props} {...props}

View File

@@ -1,8 +0,0 @@
import NextAuth from "next-auth"
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { prisma } from "./prisma"
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [],
})

View File

@@ -1,29 +0,0 @@
import { z } from 'zod'
export const SignupFormSchema = z.object({
name: z
.string()
.min(2, { message: 'Name must be at least 2 characters long.' })
.trim(),
email: z.string().email({ message: 'Please enter a valid email.' }).trim(),
password: z
.string()
.min(8, { message: 'Be at least 8 characters long' })
.regex(/[a-zA-Z]/, { message: 'Contain at least one letter.' })
.regex(/[0-9]/, { message: 'Contain at least one number.' })
.regex(/[^a-zA-Z0-9]/, {
message: 'Contain at least one special character.',
})
.trim(),
})
export type FormState =
| {
errors?: {
name?: string[]
email?: string[]
password?: string[]
}
message?: string
}
| undefined

View File

@@ -1,4 +1,4 @@
import { PrismaClient } from '@prisma/client' import { PrismaClient } from "@/generated/prisma/client"
const globalForPrisma = global as unknown as { const globalForPrisma = global as unknown as {
prisma: PrismaClient | undefined prisma: PrismaClient | undefined

27
src/types/auth.d.ts vendored
View File

@@ -1,27 +0,0 @@
export interface User {
id: number
phone: string
name?: string | null
verifiedPhone: boolean
}
export interface Session {
id: string
userId: number
expires: Date
}
export interface LoginResponse {
success: boolean
error?: string
user?: {
id: number
phone: string
name?: string | null
}
}
export interface RegisterResponse {
success: boolean
error?: string
}