7 Commits

Author SHA1 Message Date
wmp
02fc0676bf 应用 eslint 规则 2025-09-23 11:30:06 +08:00
wmp
ee54aa2465 引入 eslint 配置 2025-09-23 11:26:45 +08:00
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
42 changed files with 1547 additions and 1289 deletions

View File

@@ -26,6 +26,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"@stylistic/eslint-plugin": "^5.4.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
@@ -103,7 +104,7 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.9", "", { "os": "win32", "cpu": "x64" }, "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="],
@@ -309,6 +310,8 @@
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@stylistic/eslint-plugin": ["@stylistic/eslint-plugin@5.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.0", "@typescript-eslint/types": "^8.44.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "estraverse": "^5.3.0", "picomatch": "^4.0.3" }, "peerDependencies": { "eslint": ">=9.0.0" } }, "sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.12", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.12" } }, "sha512-3hm9brwvQkZFe++SBt+oLjo4OLDtkvlE8q2WalaD/7QWaeM7KEJbAiY/LJZUaCs7Xa8aUu4xy3uoyX4q54UVdQ=="],
@@ -367,7 +370,7 @@
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.40.0", "", { "dependencies": { "@typescript-eslint/types": "8.40.0", "@typescript-eslint/typescript-estree": "8.40.0", "@typescript-eslint/utils": "8.40.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow=="], "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.40.0", "", { "dependencies": { "@typescript-eslint/types": "8.40.0", "@typescript-eslint/typescript-estree": "8.40.0", "@typescript-eslint/utils": "8.40.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-eE60cK4KzAc6ZrzlJnflXdrMqOBaugeukWICO2rB0KNvwdIMaEaYiywwHMzA1qFpTxrLhN9Lp4E/00EgWcD3Ow=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="], "@typescript-eslint/types": ["@typescript-eslint/types@8.44.1", "", {}, "sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.40.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.40.0", "@typescript-eslint/tsconfig-utils": "8.40.0", "@typescript-eslint/types": "8.40.0", "@typescript-eslint/visitor-keys": "8.40.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ=="], "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.40.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.40.0", "@typescript-eslint/tsconfig-utils": "8.40.0", "@typescript-eslint/types": "8.40.0", "@typescript-eslint/visitor-keys": "8.40.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ=="],
@@ -1067,12 +1070,30 @@
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"@typescript-eslint/parser/@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="],
"@typescript-eslint/project-service/@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="],
"@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="],
"@typescript-eslint/type-utils/@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="],
"@typescript-eslint/typescript-estree/@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="],
"@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=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "@typescript-eslint/typescript-estree/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="],
"@typescript-eslint/utils/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
"@typescript-eslint/utils/@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="],
"@typescript-eslint/visitor-keys/@typescript-eslint/types": ["@typescript-eslint/types@8.40.0", "", {}, "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg=="],
"eslint/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], "eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1094,5 +1115,9 @@
"@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=="],
"@typescript-eslint/utils/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"eslint/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
} }
} }

View File

@@ -1,25 +1,28 @@
import { dirname } from "path"; import { dirname } from 'path'
import { fileURLToPath } from "url"; import { fileURLToPath } from 'url'
import { FlatCompat } from "@eslint/eslintrc"; import { FlatCompat } from '@eslint/eslintrc'
import stylistic from '@stylistic/eslint-plugin'
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename); const __dirname = dirname(__filename)
const compat = new FlatCompat({ const compat = new FlatCompat({
baseDirectory: __dirname, baseDirectory: __dirname,
}); })
const eslintConfig = [ const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"), ...compat.extends('next/core-web-vitals', 'next/typescript'),
stylistic.configs.recommended,
{ {
ignores: [ rules: {
"node_modules/**", '@stylistic/jsx-closing-bracket-location': 'off',
".next/**", '@stylistic/jsx-curly-newline': 'off',
"out/**", '@stylistic/jsx-one-expression-per-line': 'off',
"build/**", '@stylistic/multiline-ternary': 'off',
"next-env.d.ts", '@typescript-eslint/no-empty-object-type': 'off',
], '@typescript-eslint/no-unused-vars': 'off',
},
}, },
]; ]
export default eslintConfig; export default eslintConfig

View File

@@ -1,4 +1,4 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
@@ -6,6 +6,6 @@ const nextConfig: NextConfig = {
ignoreDuringBuilds: true, ignoreDuringBuilds: true,
}, },
output: 'standalone', output: 'standalone',
}; }
export default nextConfig; export default nextConfig

View File

@@ -1,6 +1,6 @@
{ {
"name": "my-app", "name": "my-app",
"version": "0.2.0", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
@@ -31,6 +31,7 @@
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"@stylistic/eslint-plugin": "^5.4.0",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",

View File

@@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ['@tailwindcss/postcss'],
}; }
export default config; export default config

View File

@@ -13,14 +13,14 @@ import { useAuthStore } from '@/store/auth'
import { toast, Toaster } from 'sonner' import { toast, Toaster } from 'sonner'
const formSchema = z.object({ const formSchema = z.object({
account: z.string().min(3, '账号至少需要3个字符'), account: z.string().min(3, '账号至少需要3个字符'),
password: z.string().min(6, '密码至少需要6个字符'), password: z.string().min(6, '密码至少需要6个字符'),
}) })
export default function LoginPage() { export default function LoginPage() {
const router = useRouter() const router = useRouter()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const setAuth = useAuthStore((state) => state.setAuth) const setAuth = useAuthStore(state => state.setAuth)
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -39,7 +39,7 @@ export default function LoginPage() {
}, },
body: JSON.stringify(values), body: JSON.stringify(values),
}) })
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
@@ -47,19 +47,21 @@ export default function LoginPage() {
} }
if (data.success) { if (data.success) {
toast.success("登录成功", { toast.success('登录成功', {
description: "正在跳转到仪表盘...", description: '正在跳转到仪表盘...',
}) })
setAuth(true) setAuth(true)
await new Promise(resolve => setTimeout(resolve, 1000)) await new Promise(resolve => setTimeout(resolve, 1000))
router.push('/dashboard') router.push('/dashboard')
router.refresh() router.refresh()
} }
} catch (error) { }
toast.error("登录失败", { catch (error) {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试", toast.error('登录失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
}) })
} finally { }
finally {
setLoading(false) setLoading(false)
} }
} }
@@ -82,9 +84,9 @@ export default function LoginPage() {
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<User 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}
/> />
</div> </div>
@@ -109,20 +111,22 @@ export default function LoginPage() {
</FormItem> </FormItem>
)} )}
/> />
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
disabled={loading} disabled={loading}
size="lg" size="lg"
> >
{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 className="flex items-center gap-2">
... <div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
</div> ...
) : ( </div>
'登录' )
)} : (
'登录'
)}
</Button> </Button>
</form> </form>
</Form> </Form>
@@ -132,4 +136,4 @@ export default function LoginPage() {
<Toaster richColors /> <Toaster richColors />
</> </>
) )
} }

View File

@@ -4,76 +4,74 @@ import { compare } from 'bcryptjs'
import { z } from 'zod' import { z } from 'zod'
const loginSchema = z.object({ const loginSchema = z.object({
account: z.string().min(3, '账号至少需要3个字符'), account: z.string().min(3, '账号至少需要3个字符'),
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 { account, password } = loginSchema.parse(body) const { account, password } = loginSchema.parse(body)
const user = await prisma.user.findFirst({
where: {
OR: [
{ account: account.trim() },
{ password: account.trim() },
],
},
})
const user = await prisma.user.findFirst({ if (!user) {
where: { return NextResponse.json(
OR: [ { success: false, error: '用户不存在' },
{ account: account.trim() }, { status: 401 },
{ password: account.trim() } )
]
},
})
if (!user) {
return NextResponse.json(
{ success: false, error: '用户不存在' },
{ status: 401 }
)
}
// 验证密码
const passwordMatch = await compare(password, user.password || '')
if (!passwordMatch) {
return NextResponse.json({
success: false,
error: '密码错误'
}, { status: 401 })
}
// 创建会话
const sessionToken = crypto.randomUUID()
await prisma.session.create({
data: {
id: sessionToken,
userId: user.id,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
})
// 设置cookie
const response = NextResponse.json({
success: true,
user: {
id: user.id,
account: user.account,
name: user.name
}
})
response.cookies.set('session', sessionToken, {
httpOnly: true,
// secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7
})
return response
} catch (error) {
console.error('登录错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 }
)
} }
}
// 验证密码
const passwordMatch = await compare(password, user.password || '')
if (!passwordMatch) {
return NextResponse.json({
success: false,
error: '密码错误',
}, { status: 401 })
}
// 创建会话
const sessionToken = crypto.randomUUID()
await prisma.session.create({
data: {
id: sessionToken,
userId: user.id,
expires: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
})
// 设置cookie
const response = NextResponse.json({
success: true,
user: {
id: user.id,
account: user.account,
name: user.name,
},
})
response.cookies.set('session', sessionToken, {
httpOnly: true,
// secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7,
})
return response
}
catch (error) {
console.error('登录错误:', error)
return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' },
{ status: 500 },
)
}
}

View File

@@ -3,36 +3,36 @@ import { cookies } from 'next/headers'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
export async function POST() { export async function POST() {
try { try {
const cookieStore = await cookies() const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value const sessionToken = cookieStore.get('session')?.value
// 删除数据库中的session如果存在 // 删除数据库中的session如果存在
if (sessionToken) { if (sessionToken) {
await prisma.session.deleteMany({ await prisma.session.deleteMany({
where: { id: sessionToken } where: { id: sessionToken },
}).catch(() => { }).catch(() => {
// 忽略删除错误确保cookie被清除 // 忽略删除错误确保cookie被清除
}) })
}
// 清除cookie
const response = NextResponse.json({ success: true })
response.cookies.set('session', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0, // 立即过期
path: '/',
})
return response
} catch (error) {
console.error('退出错误:', error)
return NextResponse.json(
{ success: false, error: '退出失败' },
{ status: 500 }
)
} }
}
// 清除cookie
const response = NextResponse.json({ success: true })
response.cookies.set('session', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0, // 立即过期
path: '/',
})
return response
}
catch (error) {
console.error('退出错误:', error)
return NextResponse.json(
{ success: false, error: '退出失败' },
{ status: 500 },
)
}
}

View File

@@ -3,172 +3,269 @@ import { prisma } from '@/lib/prisma'
// 处理 BigInt 序列化 // 处理 BigInt 序列化
function safeSerialize(data: unknown) { function safeSerialize(data: unknown) {
return JSON.parse(JSON.stringify(data, (key, value) => return JSON.parse(JSON.stringify(data, (key, value) =>
typeof value === 'bigint' ? value.toString() : value typeof value === 'bigint' ? value.toString() : value,
)) ))
} }
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const reportType = searchParams.get('type') const reportType = searchParams.get('type')
switch (reportType) { switch (reportType) {
case 'gateway_info': case 'gateway_info':
return await getGatewayInfo() return await getGatewayInfo()
case 'gateway_config': case 'gateway_config':
return await getGatewayConfig(request) return await getGatewayConfig(request)
case 'city_config_count': case 'city_config_count':
return await getCityConfigCount() return await getCityConfigCount()
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:
return NextResponse.json({ error: 'Invalid report type' }, { status: 400 }) return NextResponse.json({ error: 'Invalid report type' }, { status: 400 })
}
} catch (error) {
console.error('API Error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
} }
}
catch (error) {
console.error('API Error:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
} }
// 获取网关基本信息 // 获取网关基本信息
async function getGatewayInfo() { async function getGatewayInfo() {
try { try {
const result = await prisma.$queryRaw` const result = await prisma.$queryRaw`
SELECT macaddr, inner_ip, setid, enable SELECT macaddr, inner_ip, setid, enable
FROM token FROM token
ORDER BY macaddr ORDER BY macaddr
` `
return NextResponse.json(safeSerialize(result)) return NextResponse.json(safeSerialize(result))
} catch (error) { }
console.error('Gateway info query error:', error) catch (error) {
return NextResponse.json({ error: '查询网关信息失败' }, { status: 500 }) console.error('Gateway info query error:', error)
} return NextResponse.json({ error: '查询网关信息失败' }, { status: 500 })
}
} }
// 网关配置 // 网关配置
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
} catch (error) {
console.error('Gateway config query error:', error)
return NextResponse.json({ error: '查询网关配置失败' }, { status: 500 })
} }
// 获取总数
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) {
console.error('Gateway config query error:', error)
return NextResponse.json({ error: '查询网关配置失败' }, { status: 500 })
}
} }
// 城市节点配置数量统计 // 城市节点配置数量统计
async function getCityConfigCount() { async function getCityConfigCount() {
try { try {
const result = await prisma.$queryRaw` const result = await prisma.$queryRaw`
SELECT c.city, COUNT(e.id) as node_count SELECT c.city, COUNT(e.id) as node_count
FROM cityhash c FROM cityhash c
LEFT JOIN edge e ON c.id = e.city_id LEFT JOIN edge e ON c.id = e.city_id
GROUP BY c.city GROUP BY c.city
` `
return NextResponse.json(safeSerialize(result)) return NextResponse.json(safeSerialize(result))
} catch (error) { }
console.error('City config count query error:', error) catch (error) {
return NextResponse.json({ error: '查询城市配置失败' }, { status: 500 }) console.error('City config count query error:', error)
} return NextResponse.json({ error: '查询城市配置失败' }, { status: 500 })
}
} }
// 城市节点数量分布 // 城市节点数量分布
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) { }
console.error('City node count query error:', error) catch (error) {
return NextResponse.json({ error: '查询城市节点失败' }, { status: 500 }) console.error('City node count query error:', error)
} return NextResponse.json({ error: '查询城市节点失败' }, { status: 500 })
}
} }
// 城市分配状态 // 城市分配状态
async function getAllocationStatus() { async function getAllocationStatus(request: NextRequest) {
try { try {
// 使用参数化查询防止SQL注入 const { searchParams } = new URL(request.url)
const result = await prisma.$queryRaw` const hours = searchParams.get('hours') || '24'
SELECT const hoursNum = parseInt(hours) || 24
// 使用参数化查询防止SQL注入
const result = await prisma.$queryRaw`
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) { }
console.error('Allocation status query error:', error) catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error' console.error('Allocation status query error:', error)
return NextResponse.json( const errorMessage = error instanceof Error ? error.message : 'Unknown error'
{ error: '查询分配状态失败: ' + errorMessage }, return NextResponse.json(
{ status: 500 } { error: '查询分配状态失败: ' + errorMessage },
) { status: 500 },
} )
}
} }
// 获取节点信息 // 获取节点信息
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)) `
} catch (error) { return NextResponse.json({
console.error('Edge nodes query error:', error) data: safeSerialize(result),
return NextResponse.json({ error: '查询边缘节点失败' }, { status: 500 }) totalCount: totalCount,
} currentPage: Math.floor(offset / limit) + 1,
} totalPages: Math.ceil(totalCount / limit),
})
}
catch (error) {
console.error('Edge nodes query error:', error)
return NextResponse.json({ error: '查询边缘节点失败' }, { status: 500 })
}
}

View File

@@ -20,14 +20,14 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
users users,
}) })
}
} catch (error) { catch (error) {
console.error('获取用户列表错误:', error) console.error('获取用户列表错误:', error)
return NextResponse.json( return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' }, { success: false, error: '服务器错误,请稍后重试' },
{ status: 500 } { status: 500 },
) )
} }
} }
@@ -39,13 +39,13 @@ export async function POST(request: Request) {
// 检查用户是否已存在 // 检查用户是否已存在
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findUnique({
where: { account } where: { account },
}) })
if (existingUser) { if (existingUser) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: '用户账号已存在' }, { success: false, error: '用户账号已存在' },
{ status: 400 } { status: 400 },
) )
} }
@@ -66,19 +66,18 @@ export async function POST(request: Request) {
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
user: userWithoutPassword user: userWithoutPassword,
}) })
}
} catch (error) { catch (error) {
console.error('创建用户错误:', error) console.error('创建用户错误:', error)
return NextResponse.json( return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' }, { success: false, error: '服务器错误,请稍后重试' },
{ status: 500 } { status: 500 },
) )
} }
} }
// 删除用户 // 删除用户
export async function DELETE(request: Request) { export async function DELETE(request: Request) {
try { try {
@@ -89,7 +88,7 @@ export async function DELETE(request: Request) {
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: '用户ID不能为空' }, { success: false, error: '用户ID不能为空' },
{ status: 400 } { status: 400 },
) )
} }
@@ -97,36 +96,37 @@ export async function DELETE(request: Request) {
if (isNaN(userId)) { if (isNaN(userId)) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: '无效的用户ID' }, { success: false, error: '无效的用户ID' },
{ status: 400 } { status: 400 },
) )
} }
// 检查用户是否存在 // 检查用户是否存在
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findUnique({
where: { id: userId } where: { id: userId },
}) })
if (!existingUser) { if (!existingUser) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: '用户不存在' }, { success: false, error: '用户不存在' },
{ status: 404 } { status: 404 },
) )
} }
// 删除用户 // 删除用户
await prisma.user.delete({ await prisma.user.delete({
where: { id: userId } where: { id: userId },
}) })
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
message: '用户删除成功' message: '用户删除成功',
}) })
} catch (error) { }
catch (error) {
console.error('删除用户错误:', error) console.error('删除用户错误:', error)
return NextResponse.json( return NextResponse.json(
{ success: false, error: '服务器错误,请稍后重试' }, { success: false, error: '服务器错误,请稍后重试' },
{ status: 500 } { status: 500 },
) )
} }
} }

View File

@@ -1,9 +1,10 @@
'use client' 'use client'
import { useEffect, useState, useCallback } from 'react' 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,78 +23,57 @@ 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) { if (timeFilter === 'custom' && customHours) {
// 将datetime-local格式转换为SQL datetime格式 const hours = parseInt(customHours)
return customTime.replace('T', ' ') + ':00' return isNaN(hours) ? 24 : Math.max(1, hours) // 默认24小时最少1小时
} }
const now = new Date()
let filterDate
switch(timeFilter) {
case '1h':
filterDate = new Date(now.getTime() - 60 * 60 * 1000)
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', ' ')
}, [timeFilter, customTime])
return parseInt(timeFilter) || 24 // 默认24小时
}, [timeFilter, customHours])
const fetchData = useCallback(async () => { // 计算超额量
const calculateOverage = (assigned: number, count: number) => {
const overage = assigned - count
return Math.max(0, overage)
}
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}`)
const result = await response.json() const result = await response.json()
// 数据验证 // 数据验证
const validatedData = (result as ApiAllocationStatus[]).map((item) => ({ const validatedData = (result as ApiAllocationStatus[]).map(item => ({
city: item.city || '未知', city: item.city || '未知',
count: validateNumber(item.count), count: validateNumber(item.count),
assigned: validateNumber(item.assigned), assigned: validateNumber(item.assigned),
})) }))
setData(validatedData) const sortedData = validatedData.sort((a, b) => b.count - a.count)
} catch (error) {
setData(sortedData)
}
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,83 +82,85 @@ 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>
{/* 时间筛选器 */} {/* 时间筛选器 */}
<div className="mb-4 flex flex-wrap items-center gap-3"> <div className="mb-4 flex flex-wrap items-center gap-3">
<label className="font-medium">:</label> <label className="font-medium">:</label>
<select <select
value={timeFilter} value={timeFilter}
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>
)} )}
<button <button
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="bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{formatNumber(data.length)}</div>
<div className="text-sm text-blue-800"></div>
</div>
<div className="bg-orange-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-orange-600">{formatNumber(problematicCities.length)}</div>
<div className="text-sm text-orange-800"></div>
</div>
</div>
{detailed && ( <div className="flex gap-6 overflow-hidden">
<div className="overflow-x-auto"> <div className="flex w-full">
<table className="min-w-full table-auto"> <Table>
<thead> <TableHeader>
<tr className="bg-gray-50"> <TableRow className="bg-gray-50">
<th className="px-4 py-2 text-left"></th> <TableHead className="px-4 py-2 text-left"></TableHead>
<th className="px-4 py-2 text-left">IP量</th> <TableHead className="px-4 py-2 text-left">IP量</TableHead>
<th className="px-4 py-2 text-left">IP量</th> <TableHead className="px-4 py-2 text-left">IP量</TableHead>
</tr> <TableHead className="px-4 py-2 text-left"></TableHead>
</thead> </TableRow>
<tbody> </TableHeader>
<TableBody>
{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
@@ -23,16 +24,18 @@ export default function CityNodeStats() {
const response = await fetch('/api/stats?type=city_node_count') const response = await fetch('/api/stats?type=city_node_count')
const result = await response.json() const result = await response.json()
setData(result) setData(result)
} catch (error) { }
catch (error) {
console.error('获取城市节点数据失败:', error) console.error('获取城市节点数据失败:', error)
} finally { }
finally {
setLoading(false) setLoading(false)
} }
} }
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 +43,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,36 +51,39 @@ 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}
<td className="px-4 py-3 text-sm font-semibold">{item.offset}</td> </span>
</tr> </TableCell>
))} <TableCell className="px-4 py-2">{item.offset}</TableCell>
</tbody> </TableRow>
</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}`)
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
const result = await response.json()
// 计算偏移量
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}`)
const result = await response.json()
type ResultEdge = { type ResultEdge = {
id: number id: number
macaddr: string macaddr: string
@@ -61,38 +55,92 @@ 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 : '获取边缘节点数据失败')
} finally { }
finally {
setLoading(false) setLoading(false)
} }
} }
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 ? '是' : '否'
} }
const formatBoolean = (value: boolean | number): string => { // 独享IP节点格式化
const formatExclusiveIP = (value: number | boolean): string => {
if (typeof value === 'number') {
return value === 1 ? '是' : '否'
}
return value ? '是' : '否' 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 getArchColor = (arch: number): string => {
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 => {
if (seconds < 60) return `${seconds}` if (seconds < 60) return `${seconds}`
if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟` if (seconds < 3600) return `${Math.floor(seconds / 60)}分钟`
@@ -100,42 +148,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 +180,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,137 +188,75 @@ 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 === '移动'
<span className="text-sm text-gray-700"></span> ? '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>
</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">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">ID</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-700 uppercase tracking-wider">MAC地址</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">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">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"> <Pagination
<div className="text-sm text-gray-600"> page={currentPage}
{indexOfFirstItem + 1} {Math.min(indexOfLastItem, totalItems)} {totalItems} size={itemsPerPage}
</div> total={totalItems}
onPageChange={handlePageChange}
<Pagination> onSizeChange={handleSizeChange}
<PaginationContent> className="mt-4"
<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,144 +1,161 @@
'use client' 'use client'
import { useEffect, useState, Suspense } 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
public: string public: string
inner_ip: string inner_ip: string
ischange: number ischange: number
isonline: number isonline: number
}
interface ApiResponse {
data: GatewayConfig[]
totalCount: number
currentPage: number
totalPages: number
} }
function GatewayConfigContent() { 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()
// 监听URL的mac参数变化同步到输入框并触发查询 // 分页状态
const [currentPage, setCurrentPage] = useState(1)
const [itemsPerPage, setItemsPerPage] = useState(100)
const [totalItems, setTotalItems] = useState(0)
// 判断是否为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) // 重置到第一页
} else { fetchData(urlMac, 1, itemsPerPage)
// 如果没有mac参数显示空状态或默认查询 }
setData([]) else {
setSuccess('请输入MAC地址查询网关配置信息') setMacAddress('')
setCurrentPage(1) // 重置到第一页
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 setTotalItems(result.totalCount || 0)
user: string }
public: string catch (error) {
inner_ip: string console.error('获取网关配置失败:', error)
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)
setSuccess(`成功查询到 ${validatedData.length} 条网关配置信息`)
} catch (error) {
console.error('Failed to fetch gateway config:', error)
setError(error instanceof Error ? error.message : '获取网关配置失败') setError(error instanceof Error ? error.message : '获取网关配置失败')
setData([]) setData([])
} finally { setTotalItems(0)
}
finally {
setLoading(false) setLoading(false)
} }
} }
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
@@ -148,7 +165,7 @@ function GatewayConfigContent() {
</button> </button>
</div> </div>
{error && ( {error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md"> <div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-center text-red-800"> <div className="flex items-center text-red-800">
@@ -159,19 +176,8 @@ function GatewayConfigContent() {
</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 ? (
<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>
@@ -179,102 +185,85 @@ function GatewayConfigContent() {
</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>
@@ -283,15 +272,15 @@ function GatewayConfigContent() {
export default function GatewayConfig() { export default function GatewayConfig() {
return ( return (
<Suspense fallback={ <Suspense fallback={(
<div className="bg-white shadow rounded-lg p-6"> <div className="bg-white shadow rounded-lg p-6">
<div className="text-center py-12"> <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> <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> <p className="mt-4 text-gray-600">...</p>
</div> </div>
</div> </div>
}> )}>
<GatewayConfigContent /> <GatewayConfigContent />
</Suspense> </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,33 +16,82 @@ 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) =>
} catch (error) { sortByIpAddress(a.inner_ip, b.inner_ip),
)
setData(sortedData)
setFilteredData(sortedData) // 初始化时设置filteredData
}
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 : '获取网关信息失败')
} finally { }
finally {
setLoading(false) setLoading(false)
} }
} }
@@ -46,14 +101,14 @@ export default function Gatewayinfo() {
} }
const getStatusClass = (enable: number) => { const getStatusClass = (enable: number) => {
return enable === 1 return enable === 1
? 'bg-green-100 text-green-800' ? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800' : 'bg-red-100 text-red-800'
} }
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>
@@ -70,69 +125,100 @@ export default function Gatewayinfo() {
} }
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="overflow-x-auto"> <div className="flex gap-6 overflow-hidden">
<table className="min-w-full table-auto"> <div className="flex-3 w-full flex">
<thead> <Table>
<tr className="bg-gray-50"> <TableHeader>
<th className="px-4 py-2 text-left">MAC地址</th> <TableRow className="bg-gray-50">
<th className="px-4 py-2 text-left">IP</th> <TableHead className="px-4 py-2 text-left">MAC地址</TableHead>
<th className="px-4 py-2 text-left"></th> <TableHead className="px-4 py-2 text-left">IP</TableHead>
<th className="px-4 py-2 text-left"></th> <TableHead className="px-4 py-2 text-left"></TableHead>
</tr> <TableHead className="px-4 py-2 text-left"></TableHead>
</thead> </TableRow>
<tbody> </TableHeader>
{data.map((item, index) => ( <TableBody>
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}> {filteredData.map((item, index) => (
<td className="px-4 py-2"> <TableRow
<button key={index}
onClick={() => { className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`); >
}} <TableCell className="px-4 py-2">
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer" <button
> onClick={() => {
{item.macaddr} router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`)
</button> }}
</td> className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
<td className="px-4 py-2">{item.inner_ip}</td> >
<td className="px-4 py-2">{item.setid}</td> {item.macaddr}
<td className="px-4 py-2"> </button>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}> </TableCell>
{getStatusText(item.enable)} <TableCell className="px-4 py-2">{item.inner_ip}</TableCell>
</span> <TableCell className="px-4 py-2">{item.setid}</TableCell>
</td> <TableCell className="px-4 py-2">
</tr> <span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}>
))} {getStatusText(item.enable)}
</tbody> </span>
</table> </TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-1 flex-col gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-blue-600">{data.length}</div>
<div className="text-sm text-blue-800"></div>
</div>
<div className="bg-green-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-green-600">
{data.filter(item => item.enable === 1).length}
</div>
<div className="text-sm text-green-800"></div>
</div>
<div className="bg-red-50 p-4 rounded-lg">
<div className="text-2xl font-bold text-red-600">
{data.filter(item => item.enable === 0).length}
</div>
<div className="text-sm text-red-800"></div>
</div>
</div>
</div> </div>
</div> </div>
) )
} }

View File

@@ -9,14 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { User, Lock, Search, Trash2, Plus, X } from 'lucide-react' import { User, Lock, Search, Trash2, Plus, X } from 'lucide-react'
import { toast, Toaster } from 'sonner' import { toast, Toaster } from 'sonner'
import { import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// 用户类型定义 // 用户类型定义
interface UserData { interface UserData {
@@ -29,12 +22,11 @@ const formSchema = z.object({
account: z.string().min(3, '账号至少需要3个字符'), account: z.string().min(3, '账号至少需要3个字符'),
password: z.string().min(6, '密码至少需要6个字符'), password: z.string().min(6, '密码至少需要6个字符'),
confirmPassword: z.string().min(6, '密码至少需要6个字符'), confirmPassword: z.string().min(6, '密码至少需要6个字符'),
}).refine((data) => data.password === data.confirmPassword, { }).refine(data => data.password === data.confirmPassword, {
message: "密码不匹配", message: '密码不匹配',
path: ["confirmPassword"], path: ['confirmPassword'],
}) })
export default function Settings() { export default function Settings() {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [users, setUsers] = useState<UserData[]>([]) const [users, setUsers] = useState<UserData[]>([])
@@ -50,7 +42,6 @@ export default function Settings() {
}, },
}) })
// 获取用户列表 // 获取用户列表
const fetchUsers = async () => { const fetchUsers = async () => {
try { try {
@@ -64,9 +55,10 @@ export default function Settings() {
if (data.success) { if (data.success) {
setUsers(data.users) setUsers(data.users)
} }
} catch (error) { }
toast.error("获取用户列表失败", { catch (error) {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试", toast.error('获取用户列表失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
}) })
} }
} }
@@ -90,7 +82,7 @@ export default function Settings() {
password: values.password, password: values.password,
}), }),
}) })
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
@@ -98,61 +90,63 @@ export default function Settings() {
} }
if (data.success) { if (data.success) {
toast.success("用户创建成功", { toast.success('用户创建成功', {
description: "新账户已成功添加", description: '新账户已成功添加',
}) })
form.reset() form.reset()
setIsCreateMode(false) setIsCreateMode(false)
fetchUsers() // 刷新用户列表 fetchUsers() // 刷新用户列表
} }
} catch (error) { }
toast.error("创建用户失败", { catch (error) {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试", toast.error('创建用户失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
}) })
} finally { }
finally {
setLoading(false) setLoading(false)
} }
} }
// 删除用户 // 删除用户
const handleDeleteUser = async (userId: number) => { const handleDeleteUser = async (userId: number) => {
if (!confirm('确定要删除这个用户吗?此操作不可恢复。')) {
if (!confirm('确定要删除这个用户吗?此操作不可恢复。')) { return
return }
}
try { try {
// 使用查询参数传递ID // 使用查询参数传递ID
const response = await fetch(`/api/users?id=${userId}`, { const response = await fetch(`/api/users?id=${userId}`, {
method: 'DELETE', method: 'DELETE',
}) })
const data = await response.json() const data = await response.json()
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || '删除用户失败') throw new Error(data.error || '删除用户失败')
} }
if (data.success) { if (data.success) {
toast.success("用户删除成功", { toast.success('用户删除成功', {
description: "用户账户已删除", description: '用户账户已删除',
})
fetchUsers() // 刷新用户列表
}
}
catch (error) {
toast.error('删除用户失败', {
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
}) })
fetchUsers() // 刷新用户列表
} }
} catch (error) {
toast.error("删除用户失败", {
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
})
} }
}
// 过滤用户列表 // 过滤用户列表
const filteredUsers = users.filter(user => const filteredUsers = users.filter(user =>
user.account.toLowerCase().includes(searchTerm.toLowerCase()) user.account.toLowerCase().includes(searchTerm.toLowerCase()),
) )
return ( return (
<div className="min-h-screen bg-white p-4 md:p-8"> <div className="bg-white p-4 md:p-8">
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold"></h1> <h1 className="text-3xl font-bold"></h1>
@@ -187,9 +181,9 @@ const handleDeleteUser = async (userId: number) => {
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<User 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}
/> />
</div> </div>
@@ -208,10 +202,10 @@ const handleDeleteUser = async (userId: number) => {
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
type="password" type="password"
placeholder="请输入密码" placeholder="请输入密码"
className="pl-8" className="pl-8"
{...field} {...field}
/> />
</div> </div>
@@ -230,10 +224,10 @@ const handleDeleteUser = async (userId: number) => {
<FormControl> <FormControl>
<div className="relative"> <div className="relative">
<Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Lock className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
type="password" type="password"
placeholder="请再次输入密码" placeholder="请再次输入密码"
className="pl-8" className="pl-8"
{...field} {...field}
/> />
</div> </div>
@@ -244,8 +238,8 @@ const handleDeleteUser = async (userId: number) => {
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="submit" type="submit"
disabled={loading} disabled={loading}
> >
{loading ? ( {loading ? (
@@ -257,9 +251,9 @@ const handleDeleteUser = async (userId: number) => {
'创建用户' '创建用户'
)} )}
</Button> </Button>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => setIsCreateMode(false)} onClick={() => setIsCreateMode(false)}
> >
@@ -271,23 +265,24 @@ const handleDeleteUser = async (userId: number) => {
</Card> </Card>
)} )}
<Card> {/* 用户列表直接显示在页面上 */}
<CardHeader> <div className="space-y-4">
<CardTitle></CardTitle> <div>
<CardDescription> <h2 className="text-2xl font-semibold"></h2>
<p className="text-muted-foreground"></p>
</CardDescription> </div>
<div className="relative mt-4 max-w-sm">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <div className="relative max-w-sm mt-4">
<Input <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
placeholder="搜索用户..." <Input
className="pl-8" placeholder="搜索用户..."
value={searchTerm} className="pl-8"
onChange={(e) => setSearchTerm(e.target.value)} value={searchTerm}
/> onChange={e => setSearchTerm(e.target.value)}
</div> />
</CardHeader> </div>
<CardContent>
<div className="border rounded-lg">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -299,23 +294,23 @@ const handleDeleteUser = async (userId: number) => {
<TableBody> <TableBody>
{filteredUsers.length === 0 ? ( {filteredUsers.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center py-4"> <TableCell colSpan={3} className="text-center py-4">
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredUsers.map((user) => ( filteredUsers.map(user => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell className="font-medium">{user.account}</TableCell> <TableCell className="font-medium">{user.account}</TableCell>
<TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell> <TableCell>{new Date(user.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-5 border-0 hover:bg-transparent"
onClick={() => handleDeleteUser(Number(user.id))} onClick={() => handleDeleteUser(Number(user.id))}
> ><Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
@@ -324,10 +319,10 @@ const handleDeleteUser = async (userId: number) => {
)} )}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </div>
</Card> </div>
</div> </div>
<Toaster richColors /> <Toaster richColors />
</div> </div>
) )
} }

View File

@@ -16,7 +16,7 @@ const tabs = [
{ id: 'city', label: '城市信息' }, { id: 'city', label: '城市信息' },
{ id: 'allocation', label: '分配状态' }, { id: 'allocation', label: '分配状态' },
{ id: 'edge', label: '节点信息' }, { id: 'edge', label: '节点信息' },
{ id: 'setting', label: '设置'} { id: 'setting', label: '设置' },
] ]
function DashboardContent() { function DashboardContent() {
@@ -24,7 +24,7 @@ function DashboardContent() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
// 监听URL参数变化 // 监听URL参数变化
useEffect(() => { useEffect(() => {
const urlTab = searchParams.get('tab') const urlTab = searchParams.get('tab')
@@ -40,17 +40,20 @@ function DashboardContent() {
const response = await fetch('/api/auth/logout', { const response = await fetch('/api/auth/logout', {
method: 'POST', method: 'POST',
}) })
if (response.ok) { if (response.ok) {
// 退出成功后跳转到登录页 // 退出成功后跳转到登录页
router.push('/login') router.push('/login')
router.refresh() router.refresh()
} else { }
else {
console.error('退出失败') console.error('退出失败')
} }
} catch (error) { }
catch (error) {
console.error('退出错误:', error) console.error('退出错误:', error)
} finally { }
finally {
setIsLoading(false) setIsLoading(false)
} }
} }
@@ -64,14 +67,14 @@ function DashboardContent() {
} }
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">
<h1 className="text-xl font-bold text-gray-900"></h1> <h1 className="text-xl font-bold text-gray-900"></h1>
</div> </div>
{/* 简化的退出按钮 */} {/* 简化的退出按钮 */}
<button <button
onClick={handleLogout} onClick={handleLogout}
@@ -85,14 +88,14 @@ function DashboardContent() {
</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'
@@ -104,13 +107,13 @@ function DashboardContent() {
</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/>} {activeTab === 'setting' && <Settings />}
</div> </div>
</div> </div>
</div> </div>
@@ -119,15 +122,15 @@ function DashboardContent() {
export default function Dashboard() { export default function Dashboard() {
return ( return (
<Suspense fallback={ <Suspense fallback={(
<div className="min-h-screen bg-gray-100 flex items-center justify-center"> <div className=" bg-gray-100 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div> <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> <p className="mt-4 text-gray-600">...</p>
</div> </div>
</div> </div>
}> )}>
<DashboardContent /> <DashboardContent />
</Suspense> </Suspense>
) )
} }

View File

@@ -116,7 +116,8 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }

View File

@@ -1,25 +1,23 @@
import type { Metadata } from "next"; import type { Metadata } from 'next'
import "./globals.css"; import './globals.css'
import { Toaster } from "sonner"; import { Toaster } from 'sonner'
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: 'Create Next App',
description: "Generated by create next app", description: 'Generated by create next app',
}; }
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode
}>) { }>) {
return ( return (
<html lang="zh-Hans"> <html lang="zh-Hans">
<body <body className="antialiased">
className={`antialiased`} {children}
> <Toaster richColors />
{children}
<Toaster richColors />
</body> </body>
</html> </html>
); )
} }

View File

@@ -1,29 +1,29 @@
import * as React from "react" import * as React from 'react'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{ {
variants: { variants: {
variant: { variant: {
default: "bg-card text-card-foreground", default: 'bg-card text-card-foreground',
destructive: destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} },
) )
function Alert({ function Alert({
className, className,
variant, variant,
...props ...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) { }: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return ( return (
<div <div
data-slot="alert" data-slot="alert"
@@ -34,13 +34,13 @@ function Alert({
) )
} }
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="alert-title" data-slot="alert-title"
className={cn( className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight", 'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className className,
)} )}
{...props} {...props}
/> />
@@ -50,13 +50,13 @@ function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
function AlertDescription({ function AlertDescription({
className, className,
...props ...props
}: React.ComponentProps<"div">) { }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="alert-description" data-slot="alert-description"
className={cn( className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed", 'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
className className,
)} )}
{...props} {...props}
/> />

View File

@@ -1,28 +1,28 @@
import * as React from "react" import * as React from 'react'
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden',
{ {
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
destructive: destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
}, },
} },
) )
function Badge({ function Badge({
@@ -30,9 +30,9 @@ function Badge({
variant, variant,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"span"> & }: React.ComponentProps<'span'>
VariantProps<typeof badgeVariants> & { asChild?: boolean }) { & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span" const Comp = asChild ? Slot : 'span'
return ( return (
<Comp <Comp

View File

@@ -1,38 +1,38 @@
import * as React from "react" import * as React from 'react'
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot'
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*=\'size-\'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
{ {
variants: { variants: {
variant: { variant: {
default: default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: "text-primary underline-offset-4 hover:underline", link: 'text-primary underline-offset-4 hover:underline',
}, },
size: { size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3", default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: "size-9", icon: 'size-9',
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: 'default',
size: "default", size: 'default',
}, },
} },
) )
function Button({ function Button({
@@ -41,11 +41,11 @@ function Button({
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: React.ComponentProps<'button'>
VariantProps<typeof buttonVariants> & { & VariantProps<typeof buttonVariants> & {
asChild?: boolean asChild?: boolean
}) { }) {
const Comp = asChild ? Slot : "button" const Comp = asChild ? Slot : 'button'
return ( return (
<Comp <Comp

View File

@@ -1,81 +1,81 @@
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Card({ className, ...props }: React.ComponentProps<"div">) { function Card({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card" data-slot="card"
className={cn( className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", 'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", '@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn("leading-none font-semibold", className)} className={cn('leading-none font-semibold', className)}
{...props} {...props}
/> />
) )
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) )
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", 'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-6", className)} className={cn('px-6', className)}
{...props} {...props}
/> />
) )
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)} className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
{...props} {...props}
/> />
) )

View File

@@ -1,10 +1,10 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from '@radix-ui/react-dialog'
import { XIcon } from "lucide-react" import { XIcon } from 'lucide-react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Dialog({ function Dialog({
...props ...props
@@ -38,8 +38,8 @@ function DialogOverlay({
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
/> />
@@ -60,8 +60,8 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", '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 className,
)} )}
{...props} {...props}
> >
@@ -80,23 +80,23 @@ function DialogContent({
) )
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props} {...props}
/> />
) )
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", 'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
className className,
)} )}
{...props} {...props}
/> />
@@ -110,7 +110,7 @@ function DialogTitle({
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)} className={cn('text-lg leading-none font-semibold', className)}
{...props} {...props}
/> />
) )
@@ -123,7 +123,7 @@ function DialogDescription({
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) )

View File

@@ -1,17 +1,17 @@
export default function ErrorCard({ export default function ErrorCard({
title, title,
error, error,
onRetry onRetry,
}: { }: {
title: string title: string
error: string error: string
onRetry: () => void onRetry: () => void
}) { }) {
return ( return (
<div className="bg-white shadow rounded-lg p-6 text-red-600"> <div className="bg-white shadow rounded-lg p-6 text-red-600">
<h2 className="text-lg font-semibold mb-2">{title}</h2> <h2 className="text-lg font-semibold mb-2">{title}</h2>
<p>: {error}</p> <p>: {error}</p>
<button <button
onClick={onRetry} onClick={onRetry}
className="mt-2 px-4 py-2 bg-blue-500 text-white rounded" className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
> >
@@ -19,4 +19,4 @@ export default function ErrorCard({
</button> </button>
</div> </div>
) )
} }

View File

@@ -1,8 +1,8 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label'
import { Slot } from "@radix-ui/react-slot" import { Slot } from '@radix-ui/react-slot'
import { import {
Controller, Controller,
FormProvider, FormProvider,
@@ -11,10 +11,10 @@ import {
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
} from "react-hook-form" } from 'react-hook-form'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { Label } from "@/components/ui/label" import { Label } from '@/components/ui/label'
const Form = FormProvider const Form = FormProvider
@@ -26,7 +26,7 @@ type FormFieldContextValue<
} }
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue {} as FormFieldContextValue,
) )
const FormField = < const FormField = <
@@ -50,7 +50,7 @@ const useFormField = () => {
const fieldState = getFieldState(fieldContext.name, formState) const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>") throw new Error('useFormField should be used within <FormField>')
} }
const { id } = itemContext const { id } = itemContext
@@ -70,17 +70,17 @@ type FormItemContextValue = {
} }
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue {} as FormItemContextValue,
) )
function FormItem({ className, ...props }: React.ComponentProps<"div">) { function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
const id = React.useId() const id = React.useId()
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div <div
data-slot="form-item" data-slot="form-item"
className={cn("grid gap-2", className)} className={cn('grid gap-2', className)}
{...props} {...props}
/> />
</FormItemContext.Provider> </FormItemContext.Provider>
@@ -97,7 +97,7 @@ function FormLabel({
<Label <Label
data-slot="form-label" data-slot="form-label"
data-error={!!error} data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)} className={cn('data-[error=true]:text-destructive', className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
@@ -122,22 +122,22 @@ function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
) )
} }
function FormDescription({ className, ...props }: React.ComponentProps<"p">) { function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
const { formDescriptionId } = useFormField() const { formDescriptionId } = useFormField()
return ( return (
<p <p
data-slot="form-description" data-slot="form-description"
id={formDescriptionId} id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)} className={cn('text-muted-foreground text-sm', className)}
{...props} {...props}
/> />
) )
} }
function FormMessage({ className, ...props }: React.ComponentProps<"p">) { function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
const { error, formMessageId } = useFormField() const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children const body = error ? String(error?.message ?? '') : props.children
if (!body) { if (!body) {
return null return null
@@ -147,7 +147,7 @@ function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
<p <p
data-slot="form-message" data-slot="form-message"
id={formMessageId} id={formMessageId}
className={cn("text-destructive text-sm", className)} className={cn('text-destructive text-sm', className)}
{...props} {...props}
> >
{body} {body}

View File

@@ -1,17 +1,17 @@
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Input({ className, type, ...props }: React.ComponentProps<"input">) { function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
return ( return (
<input <input
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className className,
)} )}
{...props} {...props}
/> />

View File

@@ -1,9 +1,9 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import * as LabelPrimitive from "@radix-ui/react-label" import * as LabelPrimitive from '@radix-ui/react-label'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Label({ function Label({
className, className,
@@ -13,8 +13,8 @@ function Label({
<LabelPrimitive.Root <LabelPrimitive.Root
data-slot="label" data-slot="label"
className={cn( className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", 'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className className,
)} )}
{...props} {...props}
/> />

View File

@@ -11,4 +11,4 @@ export default function LoadingCard({ title }: { title: string }) {
</div> </div>
</div> </div>
) )
} }

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,11 +252,9 @@ 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,12 +263,12 @@ 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" />
@@ -118,6 +279,7 @@ function PaginationEllipsis({
export { export {
Pagination, Pagination,
PaginationLayout,
PaginationContent, PaginationContent,
PaginationLink, PaginationLink,
PaginationItem, PaginationItem,

View File

@@ -1,10 +1,10 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from '@radix-ui/react-select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Select({ function Select({
...props ...props
@@ -26,19 +26,19 @@ function SelectValue({
function SelectTrigger({ function SelectTrigger({
className, className,
size = "default", size = 'default',
children, children,
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & { }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default" size?: 'sm' | 'default'
}) { }) {
return ( return (
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
className className,
)} )}
{...props} {...props}
> >
@@ -53,7 +53,7 @@ function SelectTrigger({
function SelectContent({ function SelectContent({
className, className,
children, children,
position = "popper", position = 'popper',
...props ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) { }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return ( return (
@@ -61,10 +61,10 @@ function SelectContent({
<SelectPrimitive.Content <SelectPrimitive.Content
data-slot="select-content" data-slot="select-content"
className={cn( className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", 'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === "popper" && position === 'popper'
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", && 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className className,
)} )}
position={position} position={position}
{...props} {...props}
@@ -72,9 +72,9 @@ function SelectContent({
<SelectScrollUpButton /> <SelectScrollUpButton />
<SelectPrimitive.Viewport <SelectPrimitive.Viewport
className={cn( className={cn(
"p-1", 'p-1',
position === "popper" && position === 'popper'
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1" && 'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1',
)} )}
> >
{children} {children}
@@ -92,7 +92,7 @@ function SelectLabel({
return ( return (
<SelectPrimitive.Label <SelectPrimitive.Label
data-slot="select-label" data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
{...props} {...props}
/> />
) )
@@ -107,8 +107,8 @@ function SelectItem({
<SelectPrimitive.Item <SelectPrimitive.Item
data-slot="select-item" data-slot="select-item"
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", 'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
className className,
)} )}
{...props} {...props}
> >
@@ -129,7 +129,7 @@ function SelectSeparator({
return ( return (
<SelectPrimitive.Separator <SelectPrimitive.Separator
data-slot="select-separator" data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
{...props} {...props}
/> />
) )
@@ -143,8 +143,8 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpButton <SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button" data-slot="select-scroll-up-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", 'flex cursor-default items-center justify-center py-1',
className className,
)} )}
{...props} {...props}
> >
@@ -161,8 +161,8 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownButton <SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button" data-slot="select-scroll-down-button"
className={cn( className={cn(
"flex cursor-default items-center justify-center py-1", 'flex cursor-default items-center justify-center py-1',
className className,
)} )}
{...props} {...props}
> >

View File

@@ -1,10 +1,10 @@
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Skeleton({ className, ...props }: React.ComponentProps<"div">) { function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return ( return (
<div <div
data-slot="skeleton" data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)} className={cn('bg-accent animate-pulse rounded-md', className)}
{...props} {...props}
/> />
) )

View File

@@ -1,20 +1,20 @@
"use client" 'use client'
import { useTheme } from "next-themes" import { useTheme } from 'next-themes'
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, ToasterProps } from 'sonner'
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = 'system' } = useTheme()
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps['theme']}
className="toaster group" className="toaster group"
style={ style={
{ {
"--normal-bg": "var(--popover)", '--normal-bg': 'var(--popover)',
"--normal-text": "var(--popover-foreground)", '--normal-text': 'var(--popover-foreground)',
"--normal-border": "var(--border)", '--normal-border': 'var(--border)',
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}

View File

@@ -1,10 +1,10 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({ className, ...props }: React.ComponentProps<'table'>) {
return ( return (
<div <div
data-slot="table-container" data-slot="table-container"
@@ -12,79 +12,79 @@ 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>
) )
} }
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { 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}
/> />
) )
} }
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { function TableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return ( return (
<tbody <tbody
data-slot="table-body" data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)} className={cn('[&_tr:last-child]:border-0', className)}
{...props} {...props}
/> />
) )
} }
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { function TableFooter({ className, ...props }: React.ComponentProps<'tfoot'>) {
return ( return (
<tfoot <tfoot
data-slot="table-footer" data-slot="table-footer"
className={cn( className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", 'bg-muted/50 border-t font-medium [&>tr]:last:border-b-0',
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return ( return (
<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}
/> />
) )
} }
function TableHead({ className, ...props }: React.ComponentProps<"th">) { function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
return ( return (
<th <th
data-slot="table-head" data-slot="table-head"
className={cn( className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", 'text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className className,
)} )}
{...props} {...props}
/> />
) )
} }
function TableCell({ className, ...props }: React.ComponentProps<"td">) { function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
return ( return (
<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}
/> />
@@ -94,11 +94,11 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
function TableCaption({ function TableCaption({
className, className,
...props ...props
}: React.ComponentProps<"caption">) { }: React.ComponentProps<'caption'>) {
return ( return (
<caption <caption
data-slot="table-caption" data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)} className={cn('text-muted-foreground mt-4 text-sm', className)}
{...props} {...props}
/> />
) )

View File

@@ -1,9 +1,9 @@
"use client" 'use client'
import * as React from "react" import * as React from 'react'
import * as TabsPrimitive from "@radix-ui/react-tabs" import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
function Tabs({ function Tabs({
className, className,
@@ -12,7 +12,7 @@ function Tabs({
return ( return (
<TabsPrimitive.Root <TabsPrimitive.Root
data-slot="tabs" data-slot="tabs"
className={cn("flex flex-col gap-2", className)} className={cn('flex flex-col gap-2', className)}
{...props} {...props}
/> />
) )
@@ -26,8 +26,8 @@ function TabsList({
<TabsPrimitive.List <TabsPrimitive.List
data-slot="tabs-list" data-slot="tabs-list"
className={cn( className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]", 'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
className className,
)} )}
{...props} {...props}
/> />
@@ -42,8 +42,8 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", 'data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
className className,
)} )}
{...props} {...props}
/> />
@@ -57,7 +57,7 @@ function TabsContent({
return ( return (
<TabsPrimitive.Content <TabsPrimitive.Content
data-slot="tabs-content" data-slot="tabs-content"
className={cn("flex-1 outline-none", className)} className={cn('flex-1 outline-none', className)}
{...props} {...props}
/> />
) )

View File

@@ -1,26 +1,26 @@
// 数字格式化工具函数 // 数字格式化工具函数
export const formatNumber = (num: number | string): string => { export const formatNumber = (num: number | string): string => {
const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num
return numberValue.toLocaleString('zh-CN') return numberValue.toLocaleString('zh-CN')
} }
export const formatLargeNumber = (num: number | string): string => { export const formatLargeNumber = (num: number | string): string => {
const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num
if (numberValue > 1e9) return `${(numberValue / 1e9).toFixed(1)}亿` if (numberValue > 1e9) return `${(numberValue / 1e9).toFixed(1)}亿`
if (numberValue > 1e6) return `${(numberValue / 1e6).toFixed(1)}百万` if (numberValue > 1e6) return `${(numberValue / 1e6).toFixed(1)}百万`
if (numberValue > 1e4) return `${(numberValue / 1e4).toFixed(1)}` if (numberValue > 1e4) return `${(numberValue / 1e4).toFixed(1)}`
if (numberValue > 1e3) return `${(numberValue / 1e3).toFixed(1)}` if (numberValue > 1e3) return `${(numberValue / 1e3).toFixed(1)}`
return numberValue.toLocaleString('zh-CN') return numberValue.toLocaleString('zh-CN')
} }
// 数据验证函数 // 数据验证函数
export const validateNumber = (value: unknown): number => { export const validateNumber = (value: unknown): number => {
if (typeof value === 'number') return value if (typeof value === 'number') return value
if (typeof value === 'string') { if (typeof value === 'string') {
const num = parseInt(value) const num = parseInt(value)
return isNaN(num) ? 0 : num return isNaN(num) ? 0 : num
} }
return 0 return 0
} }

View File

@@ -1,13 +1,13 @@
import { PrismaClient } from "@/generated/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
} }
export const prisma = globalForPrisma.prisma ?? new PrismaClient() export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma globalForPrisma.prisma = prisma
} }
export default prisma export default prisma

View File

@@ -1,5 +1,5 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from 'clsx'
import { twMerge } from "tailwind-merge" import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))

View File

@@ -9,7 +9,7 @@ export const config = {
const isIgnored = [ const isIgnored = [
'/login', '/login',
"/api/auth/login" '/api/auth/login',
] ]
export async function middleware(request: NextRequest) { export async function middleware(request: NextRequest) {

View File

@@ -8,12 +8,12 @@ interface AuthState {
export const useAuthStore = create<AuthState>()( export const useAuthStore = create<AuthState>()(
persist( persist(
(set) => ({ set => ({
isAuthenticated: false, isAuthenticated: false,
setAuth: (state) => set({ isAuthenticated: state }), setAuth: state => set({ isAuthenticated: state }),
}), }),
{ {
name: 'auth-storage', name: 'auth-storage',
} },
) ),
) )