Compare commits
9 Commits
v0.2.0
...
2c106e43df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c106e43df | ||
|
|
ebf50c15f1 | ||
|
|
02fc0676bf | ||
|
|
ee54aa2465 | ||
|
|
30360f1a7c | ||
|
|
0288855002 | ||
|
|
fd8fede301 | ||
|
|
826d8fc4c3 | ||
|
|
4f3671c8a6 |
29
bun.lock
29
bun.lock
@@ -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=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const config = {
|
const config = {
|
||||||
plugins: ["@tailwindcss/postcss"],
|
plugins: ['@tailwindcss/postcss'],
|
||||||
};
|
}
|
||||||
|
|
||||||
export default config;
|
export default config
|
||||||
|
|||||||
115
src/actions/auth.ts
Normal file
115
src/actions/auth.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
'use server'
|
||||||
|
import prisma, { User } from '@/lib/prisma' // 使用统一的prisma实例
|
||||||
|
import { compare } from 'bcryptjs'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
account: z.string().min(3, '账号至少需要3个字符'),
|
||||||
|
password: z.string().min(6, '密码至少需要6个字符'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function login(props: {
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const result = loginSchema.parse(props)
|
||||||
|
|
||||||
|
const user: User | null = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ account: result.account.trim() },
|
||||||
|
{ password: result.password.trim() },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '用户不存在或密码未设置',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
const passwordMatch = await compare(result.password, user.password || '')
|
||||||
|
|
||||||
|
if (!passwordMatch) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '密码错误',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
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 = {
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
account: user.account,
|
||||||
|
name: user.name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
cookieStore.set('session', sessionToken, {
|
||||||
|
httpOnly: true,
|
||||||
|
// secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: 60 * 60 * 24 * 7,
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('登录错误:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '服务器错误,请稍后重试',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
const sessionToken = cookieStore.get('session')?.value
|
||||||
|
|
||||||
|
// 删除数据库中的session(如果存在)
|
||||||
|
if (sessionToken) {
|
||||||
|
await prisma.session.deleteMany({
|
||||||
|
where: { id: sessionToken },
|
||||||
|
}).catch(() => {
|
||||||
|
// 忽略删除错误,确保cookie被清除
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除cookie
|
||||||
|
const response = { success: true }
|
||||||
|
cookieStore.set('session', '', {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 0, // 立即过期
|
||||||
|
path: '/',
|
||||||
|
})
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('退出错误:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '退出失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
src/actions/stats.ts
Normal file
258
src/actions/stats.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
'use server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
|
||||||
|
export type AllocationStatus = {
|
||||||
|
city: string
|
||||||
|
count: bigint
|
||||||
|
assigned: bigint
|
||||||
|
}
|
||||||
|
|
||||||
|
// 城市分配状态
|
||||||
|
export async function getAllocationStatus(hours: number) {
|
||||||
|
try {
|
||||||
|
const hoursNum = hours || 24
|
||||||
|
// 使用参数化查询防止SQL注入
|
||||||
|
const result: AllocationStatus[] = await prisma.$queryRaw`
|
||||||
|
SELECT
|
||||||
|
city,
|
||||||
|
c1.count AS count,
|
||||||
|
c2.assigned AS assigned
|
||||||
|
FROM
|
||||||
|
cityhash
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
city_id,
|
||||||
|
COUNT(*) AS count
|
||||||
|
FROM
|
||||||
|
edge
|
||||||
|
WHERE
|
||||||
|
active = 1
|
||||||
|
GROUP BY
|
||||||
|
city_id
|
||||||
|
) c1 ON c1.city_id = cityhash.id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
city AS city_id,
|
||||||
|
COUNT(*) AS assigned
|
||||||
|
FROM
|
||||||
|
\`change\`
|
||||||
|
WHERE
|
||||||
|
time > NOW() - INTERVAL ${hoursNum} HOUR
|
||||||
|
GROUP BY
|
||||||
|
city
|
||||||
|
) c2 ON c2.city_id = cityhash.id
|
||||||
|
WHERE
|
||||||
|
cityhash.macaddr IS NOT NULL;
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Allocation status query error:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
error: '查询分配状态失败: ' + errorMessage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GatewayInfo = {
|
||||||
|
macaddr: string
|
||||||
|
inner_ip: string
|
||||||
|
setid: string
|
||||||
|
enable: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取网关基本信息
|
||||||
|
export async function getGatewayInfo() {
|
||||||
|
try {
|
||||||
|
const result: GatewayInfo[] = await prisma.$queryRaw`
|
||||||
|
SELECT macaddr, inner_ip, setid, enable
|
||||||
|
FROM token
|
||||||
|
ORDER BY macaddr
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Gateway info query error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
error: '查询网关信息失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GatewayConfig = {
|
||||||
|
city: string
|
||||||
|
edge: string
|
||||||
|
user: string
|
||||||
|
public: string
|
||||||
|
inner_ip: string
|
||||||
|
ischange: number
|
||||||
|
isonline: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 网关配置
|
||||||
|
export async function getGatewayConfig(off: number, itemsPerPage: number, mac?: string) {
|
||||||
|
try {
|
||||||
|
const offset = off || 0
|
||||||
|
const limit = itemsPerPage || 100
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
let totalCountQuery = ''
|
||||||
|
let totalCountParams: (string | number)[] = []
|
||||||
|
|
||||||
|
if (mac) {
|
||||||
|
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 = [mac]
|
||||||
|
}
|
||||||
|
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 (mac) {
|
||||||
|
query += ' WHERE gateway.macaddr = ?'
|
||||||
|
params = [mac]
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
query += ' LIMIT ? OFFSET ?'
|
||||||
|
params.push(limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指定返回类型
|
||||||
|
const result: GatewayConfig[] = await prisma.$queryRawUnsafe<GatewayConfig[]>(query, ...params)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: result,
|
||||||
|
totalCount: totalCount,
|
||||||
|
currentPage: Math.floor(offset / limit) + 1,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Gateway config query error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
error: '查询网关配置失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type CityNode = {
|
||||||
|
city: string
|
||||||
|
count: number
|
||||||
|
hash: string
|
||||||
|
label: string
|
||||||
|
offset: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 城市节点数量分布
|
||||||
|
export async function getCityNodeCount() {
|
||||||
|
try {
|
||||||
|
const result: CityNode[] = await prisma.$queryRaw`
|
||||||
|
select c.city, c.hash, c.label, e.count, c.\`offset\`
|
||||||
|
from
|
||||||
|
cityhash c
|
||||||
|
left join (
|
||||||
|
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 {
|
||||||
|
success: true,
|
||||||
|
data: result,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('City node count query error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
error: '查询城市节点失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点信息
|
||||||
|
export async function getEdgeNodes(off: number, itemsPerPage: number) {
|
||||||
|
try {
|
||||||
|
const offset = off || 0
|
||||||
|
const limit = itemsPerPage || 100
|
||||||
|
// 获取总数 - 使用类型断言
|
||||||
|
const totalCountResult = await prisma.$queryRaw<[{ total: bigint }]>`
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM edge
|
||||||
|
WHERE active = true
|
||||||
|
`
|
||||||
|
const totalCount = Number(totalCountResult[0]?.total || 0)
|
||||||
|
|
||||||
|
// 获取分页数据
|
||||||
|
const result = await prisma.$queryRaw`
|
||||||
|
SELECT edge.id, edge.macaddr, city, public, isp, single, sole, arch, online
|
||||||
|
FROM edge
|
||||||
|
LEFT JOIN cityhash ON cityhash.id = edge.city_id
|
||||||
|
WHERE edge.active = true
|
||||||
|
ORDER BY edge.id
|
||||||
|
LIMIT ${limit} OFFSET ${offset}
|
||||||
|
`
|
||||||
|
return {
|
||||||
|
data: result,
|
||||||
|
totalCount: totalCount,
|
||||||
|
currentPage: Math.floor(offset / limit) + 1,
|
||||||
|
totalPages: Math.ceil(totalCount / limit),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('Edge nodes query error:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
error: '查询边缘节点失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/actions/user.ts
Normal file
145
src/actions/user.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { hash } from 'bcryptjs'
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: number
|
||||||
|
account: string
|
||||||
|
name: string | null
|
||||||
|
createdAt: Date
|
||||||
|
updatedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有用户
|
||||||
|
export async function findUsers(): Promise<{
|
||||||
|
success: boolean
|
||||||
|
data: User[]
|
||||||
|
error: string
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
account: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'desc',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: users,
|
||||||
|
error: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('获取用户列表错误:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
error: '服务器错误,请稍后重试',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
export async function createUser(params: {
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
name: string | ''
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
// 检查用户是否已存在
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { account: params.account },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '用户账号已存在',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密密码
|
||||||
|
const hashedPassword = await hash(params.password, 10)
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
account: params.account,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: params.name || params.account,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 不返回密码字段
|
||||||
|
const { password: _, ...userWithoutPassword } = user
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: userWithoutPassword,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('创建用户错误:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '服务器错误,请稍后重试',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
export async function removeUser(id: number) {
|
||||||
|
try {
|
||||||
|
if (!id) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '用户ID不能为空',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// const userId = parseInt(id)
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '无效的用户ID',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { id: id },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
success: false,
|
||||||
|
error: '用户不存在',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
await prisma.user.delete({
|
||||||
|
where: { id: id },
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: '用户删除成功',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('删除用户错误:', error)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '服务器错误,请稍后重试',
|
||||||
|
status: 500,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
|
|||||||
import { Lock, User } from 'lucide-react'
|
import { Lock, User } from 'lucide-react'
|
||||||
import { useAuthStore } from '@/store/auth'
|
import { useAuthStore } from '@/store/auth'
|
||||||
import { toast, Toaster } from 'sonner'
|
import { toast, Toaster } from 'sonner'
|
||||||
|
import { login } from '@/actions/auth'
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
account: z.string().min(3, '账号至少需要3个字符'),
|
account: z.string().min(3, '账号至少需要3个字符'),
|
||||||
@@ -20,7 +21,7 @@ const formSchema = z.object({
|
|||||||
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: {
|
||||||
@@ -32,34 +33,27 @@ export default function LoginPage() {
|
|||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/login', {
|
const data = await login(values)
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(values),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || '登录失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
else {
|
||||||
toast.error("登录失败", {
|
toast.error('账号或密码错误,请重新输入')
|
||||||
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
toast.error('登录失败', {
|
||||||
|
description: error instanceof Error ? error.message : '账号或密码错误,请稍后重试',
|
||||||
})
|
})
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -115,12 +109,14 @@ export default function LoginPage() {
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading
|
||||||
|
? (
|
||||||
<div className="flex items-center gap-2">
|
<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="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
登录中...
|
登录中...
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)
|
||||||
|
: (
|
||||||
'登录'
|
'登录'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { prisma } from '@/lib/prisma' // 使用统一的prisma实例
|
|
||||||
import { compare } from 'bcryptjs'
|
|
||||||
import { z } from 'zod'
|
|
||||||
|
|
||||||
const loginSchema = z.object({
|
|
||||||
account: z.string().min(3, '账号至少需要3个字符'),
|
|
||||||
password: z.string().min(6, '密码至少需要6个字符'),
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const body = await request.json()
|
|
||||||
const { account, password } = loginSchema.parse(body)
|
|
||||||
|
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
OR: [
|
|
||||||
{ account: account.trim() },
|
|
||||||
{ 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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { cookies } from 'next/headers'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
export async function POST() {
|
|
||||||
try {
|
|
||||||
const cookieStore = await cookies()
|
|
||||||
const sessionToken = cookieStore.get('session')?.value
|
|
||||||
|
|
||||||
// 删除数据库中的session(如果存在)
|
|
||||||
if (sessionToken) {
|
|
||||||
await prisma.session.deleteMany({
|
|
||||||
where: { id: sessionToken }
|
|
||||||
}).catch(() => {
|
|
||||||
// 忽略删除错误,确保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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
|
|
||||||
// 处理 BigInt 序列化
|
|
||||||
function safeSerialize(data: unknown) {
|
|
||||||
return JSON.parse(JSON.stringify(data, (key, value) =>
|
|
||||||
typeof value === 'bigint' ? value.toString() : value
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const reportType = searchParams.get('type')
|
|
||||||
|
|
||||||
switch (reportType) {
|
|
||||||
case 'gateway_info':
|
|
||||||
return await getGatewayInfo()
|
|
||||||
case 'gateway_config':
|
|
||||||
return await getGatewayConfig(request)
|
|
||||||
case 'city_config_count':
|
|
||||||
return await getCityConfigCount()
|
|
||||||
case 'city_node_count':
|
|
||||||
return await getCityNodeCount()
|
|
||||||
case 'allocation_status':
|
|
||||||
return await getAllocationStatus()
|
|
||||||
case 'edge_nodes':
|
|
||||||
return await getEdgeNodes(request)
|
|
||||||
default:
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取网关基本信息
|
|
||||||
async function getGatewayInfo() {
|
|
||||||
try {
|
|
||||||
const result = await prisma.$queryRaw`
|
|
||||||
SELECT macaddr, inner_ip, setid, enable
|
|
||||||
FROM token
|
|
||||||
ORDER BY macaddr
|
|
||||||
`
|
|
||||||
return NextResponse.json(safeSerialize(result))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Gateway info query error:', error)
|
|
||||||
return NextResponse.json({ error: '查询网关信息失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 网关配置
|
|
||||||
async function getGatewayConfig(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const macAddress = searchParams.get('mac') || '000C29DF1647'
|
|
||||||
|
|
||||||
// 使用参数化查询防止SQL注入
|
|
||||||
const result = await prisma.$queryRaw`
|
|
||||||
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
|
|
||||||
WHERE gateway.macaddr = ${macAddress};
|
|
||||||
`
|
|
||||||
return NextResponse.json(safeSerialize(result))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Gateway config query error:', error)
|
|
||||||
return NextResponse.json({ error: '查询网关配置失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 城市节点配置数量统计
|
|
||||||
async function getCityConfigCount() {
|
|
||||||
try {
|
|
||||||
const result = await prisma.$queryRaw`
|
|
||||||
SELECT c.city, COUNT(e.id) as node_count
|
|
||||||
FROM cityhash c
|
|
||||||
LEFT JOIN edge e ON c.id = e.city_id
|
|
||||||
GROUP BY c.city
|
|
||||||
`
|
|
||||||
return NextResponse.json(safeSerialize(result))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('City config count query error:', error)
|
|
||||||
return NextResponse.json({ error: '查询城市配置失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 城市节点数量分布
|
|
||||||
async function getCityNodeCount() {
|
|
||||||
try {
|
|
||||||
const result = await prisma.$queryRaw`
|
|
||||||
SELECT c.city, c.hash, c.label, COUNT(e.id) as count, c.offset
|
|
||||||
FROM cityhash c
|
|
||||||
LEFT JOIN edge e ON c.id = e.city_id
|
|
||||||
GROUP BY c.hash, c.city, c.label, c.offset
|
|
||||||
ORDER BY count DESC
|
|
||||||
`
|
|
||||||
return NextResponse.json(safeSerialize(result))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('City node count query error:', error)
|
|
||||||
return NextResponse.json({ error: '查询城市节点失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 城市分配状态
|
|
||||||
async function getAllocationStatus() {
|
|
||||||
try {
|
|
||||||
// 使用参数化查询防止SQL注入
|
|
||||||
const result = await prisma.$queryRaw`
|
|
||||||
SELECT
|
|
||||||
city,
|
|
||||||
c1.count AS count,
|
|
||||||
c2.assigned AS assigned
|
|
||||||
FROM
|
|
||||||
cityhash
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT
|
|
||||||
city_id,
|
|
||||||
COUNT(*) AS count
|
|
||||||
FROM
|
|
||||||
edge
|
|
||||||
WHERE
|
|
||||||
active = 1
|
|
||||||
GROUP BY
|
|
||||||
city_id
|
|
||||||
) c1 ON c1.city_id = cityhash.id
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT
|
|
||||||
city AS city_id,
|
|
||||||
COUNT(*) AS assigned
|
|
||||||
FROM
|
|
||||||
\`change\`
|
|
||||||
WHERE
|
|
||||||
time > NOW() - INTERVAL 1 DAY
|
|
||||||
GROUP BY
|
|
||||||
city
|
|
||||||
) c2 ON c2.city_id = cityhash.id
|
|
||||||
WHERE
|
|
||||||
cityhash.macaddr IS NOT NULL;
|
|
||||||
`
|
|
||||||
return NextResponse.json(safeSerialize(result))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Allocation status query error:', error)
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: '查询分配状态失败: ' + errorMessage },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取节点信息
|
|
||||||
async function getEdgeNodes(request: NextRequest) {
|
|
||||||
try {
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const threshold = searchParams.get('threshold') || '20'
|
|
||||||
const limit = searchParams.get('limit') || '100'
|
|
||||||
|
|
||||||
// 使用参数化查询防止SQL注入
|
|
||||||
const result = await prisma.$queryRaw`
|
|
||||||
SELECT edge.id, edge.macaddr, city, public, isp, single, sole, arch, online
|
|
||||||
FROM edge
|
|
||||||
LEFT JOIN cityhash ON cityhash.id = edge.city_id
|
|
||||||
WHERE edge.id > ${threshold} AND active = true
|
|
||||||
LIMIT ${limit}
|
|
||||||
`
|
|
||||||
return NextResponse.json(safeSerialize(result))
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Edge nodes query error:', error)
|
|
||||||
return NextResponse.json({ error: '查询边缘节点失败' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server'
|
|
||||||
import { prisma } from '@/lib/prisma'
|
|
||||||
import { hash } from 'bcryptjs'
|
|
||||||
|
|
||||||
// 获取所有用户
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
const users = await prisma.user.findMany({
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
account: true,
|
|
||||||
name: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
createdAt: 'desc',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
users
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取用户列表错误:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '服务器错误,请稍后重试' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建用户
|
|
||||||
export async function POST(request: Request) {
|
|
||||||
try {
|
|
||||||
const { account, password, name } = await request.json()
|
|
||||||
|
|
||||||
// 检查用户是否已存在
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { account }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (existingUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '用户账号已存在' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加密密码
|
|
||||||
const hashedPassword = await hash(password, 10)
|
|
||||||
|
|
||||||
// 创建用户
|
|
||||||
const user = await prisma.user.create({
|
|
||||||
data: {
|
|
||||||
account,
|
|
||||||
password: hashedPassword,
|
|
||||||
name: name || account,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// 不返回密码字段
|
|
||||||
const { password: _, ...userWithoutPassword } = user
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
user: userWithoutPassword
|
|
||||||
})
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('创建用户错误:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '服务器错误,请稍后重试' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 删除用户
|
|
||||||
export async function DELETE(request: Request) {
|
|
||||||
try {
|
|
||||||
// 从URL中获取查询参数
|
|
||||||
const url = new URL(request.url)
|
|
||||||
const id = url.searchParams.get('id')
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '用户ID不能为空' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = parseInt(id)
|
|
||||||
if (isNaN(userId)) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '无效的用户ID' },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户是否存在
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
|
||||||
where: { id: userId }
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!existingUser) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '用户不存在' },
|
|
||||||
{ status: 404 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除用户
|
|
||||||
await prisma.user.delete({
|
|
||||||
where: { id: userId }
|
|
||||||
})
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
|
||||||
message: '用户删除成功'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除用户错误:', error)
|
|
||||||
return NextResponse.json(
|
|
||||||
{ success: false, error: '服务器错误,请稍后重试' },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +1,61 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
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 {
|
import { getAllocationStatus, type AllocationStatus } from '@/actions/stats'
|
||||||
city: string
|
|
||||||
count: number
|
|
||||||
assigned: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ApiAllocationStatus {
|
|
||||||
city?: string
|
|
||||||
count: number | string | bigint
|
|
||||||
assigned: number | string | bigint
|
|
||||||
unique_allocated_ips: number | string | bigint
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AllocationStatus({ detailed = false }: { detailed?: boolean }) {
|
export default function AllocationStatus({ detailed = false }: { detailed?: boolean }) {
|
||||||
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', ' ')
|
return parseInt(timeFilter) || 24 // 默认24小时
|
||||||
}, [timeFilter, customTime])
|
}, [timeFilter, customHours])
|
||||||
|
|
||||||
|
// 计算超额量
|
||||||
|
const calculateOverage = (assigned: number, count: number) => {
|
||||||
|
const overage = assigned - count
|
||||||
|
return Math.max(0, overage)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setError(null)
|
setError(null)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const timeCondition = getTimeCondition()
|
const hours = getTimeHours()
|
||||||
const response = await fetch(`/api/stats?type=allocation_status&time=${encodeURIComponent(timeCondition)}`)
|
const result = await getAllocationStatus(hours)
|
||||||
|
|
||||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
// 数据验证
|
// 数据验证
|
||||||
const validatedData = (result as ApiAllocationStatus[]).map((item) => ({
|
const validatedData = (result.data).map(item => ({
|
||||||
city: item.city || '未知',
|
city: item.city || '未知',
|
||||||
count: validateNumber(item.count),
|
count: item.count || BigInt(0),
|
||||||
assigned: validateNumber(item.assigned),
|
assigned: item.assigned || BigInt(0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
setData(validatedData)
|
const sortedData = validatedData.sort((a, b) => Number(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,10 +64,10 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
|
|||||||
if (loading) return <LoadingCard title="节点分配状态" />
|
if (loading) return <LoadingCard title="节点分配状态" />
|
||||||
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
|
if (error) return <ErrorCard title="节点分配状态" error={error} onRetry={fetchData} />
|
||||||
|
|
||||||
const problematicCities = data.filter(item => item.count < item.count)
|
const problematicCities = data.filter(item => item.assigned > item.count)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="flex flex-col bg-white shadow rounded-lg p-6 overflow-hidden ">
|
||||||
<h2 className="text-lg font-semibold mb-4">节点分配状态</h2>
|
<h2 className="text-lg font-semibold mb-4">节点分配状态</h2>
|
||||||
|
|
||||||
{/* 时间筛选器 */}
|
{/* 时间筛选器 */}
|
||||||
@@ -113,72 +75,58 @@ export default function AllocationStatus({ detailed = false }: { detailed?: bool
|
|||||||
<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>
|
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{timeFilter === 'custom' && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={customTime}
|
|
||||||
onChange={(e) => setCustomTime(e.target.value)}
|
|
||||||
className="border rounded p-2"
|
|
||||||
/>
|
|
||||||
<small>格式: YYYY-MM-DDTHH:MM</small>
|
|
||||||
</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="flex gap-6 overflow-hidden">
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
<div className="flex w-full">
|
||||||
<div className="text-2xl font-bold text-blue-600">{formatNumber(data.length)}</div>
|
<Table>
|
||||||
<div className="text-sm text-blue-800">监控城市数量</div>
|
<TableHeader>
|
||||||
</div>
|
<TableRow className="bg-gray-50">
|
||||||
<div className="bg-orange-50 p-4 rounded-lg">
|
<TableHead className="px-4 py-2 text-left">城市</TableHead>
|
||||||
<div className="text-2xl font-bold text-orange-600">{formatNumber(problematicCities.length)}</div>
|
<TableHead className="px-4 py-2 text-left">可用IP量</TableHead>
|
||||||
<div className="text-sm text-orange-800">需关注城市</div>
|
<TableHead className="px-4 py-2 text-left">分配IP量</TableHead>
|
||||||
</div>
|
<TableHead className="px-4 py-2 text-left">超额量</TableHead>
|
||||||
</div>
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
{detailed && (
|
<TableBody>
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full table-auto">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<th className="px-4 py-2 text-left">城市</th>
|
|
||||||
<th className="px-4 py-2 text-left">可用IP量</th>
|
|
||||||
<th className="px-4 py-2 text-left">分配IP量</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.map((item, index) => {
|
{data.map((item, index) => {
|
||||||
|
const overage = calculateOverage(Number(item.assigned), Number(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">{item.count}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">{item.assigned}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<span className={overage > 0 ? 'text-red-600 font-medium' : ''}>
|
||||||
|
{overage}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,8 @@
|
|||||||
'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 {
|
import { getCityNodeCount, type CityNode } from '@/actions/stats'
|
||||||
city: string
|
|
||||||
count: number
|
|
||||||
hash: string
|
|
||||||
label: string
|
|
||||||
offset: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CityNodeStats() {
|
export default function CityNodeStats() {
|
||||||
const [data, setData] = useState<CityNode[]>([])
|
const [data, setData] = useState<CityNode[]>([])
|
||||||
@@ -20,19 +14,20 @@ export default function CityNodeStats() {
|
|||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/stats?type=city_node_count')
|
const result = await getCityNodeCount()
|
||||||
const result = await response.json()
|
setData(result.data)
|
||||||
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 +35,7 @@ export default function CityNodeStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg p-6">
|
<div className="flex flex-col bg-white rounded-lg p-6 overflow-hidden">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h2 className="text-lg font-semibold">城市节点数量分布</h2>
|
<h2 className="text-lg font-semibold">城市节点数量分布</h2>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
@@ -48,35 +43,38 @@ 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>
|
||||||
|
<TableBody>
|
||||||
{data.map((item, index) => (
|
{data.map((item, index) => (
|
||||||
<tr key={index} className="border-b border-gray-100 hover:bg-gray-50">
|
<TableRow
|
||||||
<td className="px-4 py-3 text-sm font-medium">{item.city}</td>
|
key={index}
|
||||||
<td className="px-4 py-3 text-sm">
|
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
|
||||||
<span className="font-semibold text-gray-700">{item.count}</span>
|
>
|
||||||
</td>
|
<TableCell className="px-4 py-2">{item.city}</TableCell>
|
||||||
<td className="px-4 py-3 text-sm text-gray-500 font-mono">{item.hash}</td>
|
<TableCell className="px-4 py-2">{item.count}</TableCell>
|
||||||
<td className="px-4 py-3 text-sm">
|
<TableCell className="px-4 py-2">{item.hash}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
<span className="bg-gray-100 px-2 py-1 rounded text-gray-700">
|
<span className="bg-gray-100 px-2 py-1 rounded text-gray-700">
|
||||||
{item.label}
|
{item.label}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-3 text-sm font-semibold">{item.offset}</td>
|
<TableCell className="px-4 py-2">{item.offset}</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,14 +2,9 @@
|
|||||||
|
|
||||||
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,
|
import { getEdgeNodes } from '@/actions/stats'
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from "@/components/ui/pagination"
|
|
||||||
|
|
||||||
interface Edge {
|
interface Edge {
|
||||||
id: number
|
id: number
|
||||||
@@ -27,28 +22,27 @@ 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 offset = (currentPage - 1) * itemsPerPage
|
||||||
|
|
||||||
const result = await response.json()
|
// if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
|
||||||
|
const result = await getEdgeNodes(offset, itemsPerPage)
|
||||||
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 pageNumbers = []
|
|
||||||
const maxVisiblePages = 5
|
|
||||||
|
|
||||||
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(
|
const handleSizeChange = (size: number) => {
|
||||||
<PaginationItem key={i}>
|
setItemsPerPage(size)
|
||||||
<PaginationLink
|
setCurrentPage(1) // 重置到第一页
|
||||||
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,135 +188,73 @@ 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>
|
|
||||||
</select>
|
|
||||||
<span className="text-sm text-gray-700">条</span>
|
|
||||||
</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 ${
|
<span className={`px-2 py-1 rounded-full text-xs ${
|
||||||
item.isp === '移动' ? 'bg-blue-100 text-blue-800' :
|
item.isp === '移动'
|
||||||
item.isp === '电信' ? 'bg-purple-100 text-purple-800' :
|
? 'bg-blue-100 text-blue-800'
|
||||||
item.isp === '联通' ? 'bg-red-100 text-red-800' :
|
: item.isp === '电信'
|
||||||
'bg-gray-100 text-gray-800'
|
? 'bg-purple-100 text-purple-800'
|
||||||
}`}>
|
: item.isp === '联通'
|
||||||
|
? 'bg-red-100 text-red-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{item.isp}
|
{item.isp}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-3 text-sm text-center">
|
<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 ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getMultiIPColor(item.single)}`}>
|
||||||
item.single ? 'bg-red-100 text-red-800' : 'bg-green-100 text-green-800'
|
{formatMultiIP(item.single)}
|
||||||
}`}>
|
|
||||||
{formatBoolean(item.single)}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-3 text-sm text-center">
|
<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 ${
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getExclusiveIPColor(item.sole)}`}>
|
||||||
item.sole ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'
|
{formatExclusiveIP(item.sole)}
|
||||||
}`}>
|
|
||||||
{formatBoolean(item.sole)}
|
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-3 text-sm text-center">
|
<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 bg-yellow-100 text-yellow-800">
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getArchColor(item.arch)}`}>
|
||||||
{item.arch}
|
{formatArchType(item.arch)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</TableCell>
|
||||||
<td className="px-4 py-3 text-sm text-gray-700">
|
<TableCell className="px-4 py-3 text-sm text-gray-700">{formatOnlineTime(item.online)}</TableCell>
|
||||||
{formatOnlineTime(item.online)}
|
</TableRow>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -1,144 +1,144 @@
|
|||||||
'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'
|
||||||
|
import { getGatewayConfig, type GatewayConfig } from '@/actions/stats'
|
||||||
|
|
||||||
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
|
// }
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
const response = await fetch(`/api/stats?type=gateway_config&mac=${encodeURIComponent(mac)}`)
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
try {
|
||||||
throw new Error(result.error || '查询失败')
|
// 计算偏移量
|
||||||
}
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
|
const result = await getGatewayConfig(offset, limit, mac)
|
||||||
|
|
||||||
// 检查返回的数据是否有效
|
// 检查返回的数据是否有效
|
||||||
if (!result || result.length === 0) {
|
if (!result.data || result.data.length === 0) {
|
||||||
|
if (mac.trim()) {
|
||||||
setError(`未找到MAC地址为 ${mac} 的网关配置信息`)
|
setError(`未找到MAC地址为 ${mac} 的网关配置信息`)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
setError('未找到任何网关配置信息')
|
||||||
|
}
|
||||||
setData([])
|
setData([])
|
||||||
|
setTotalItems(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const validatedData = result.map((item: {
|
setData(result.data)
|
||||||
city: string
|
|
||||||
edge: string
|
|
||||||
user: string
|
|
||||||
public: string
|
|
||||||
inner_ip: string
|
|
||||||
ischange: number
|
|
||||||
isonline: number
|
|
||||||
}) => ({
|
|
||||||
city: item.city,
|
|
||||||
edge: item.edge,
|
|
||||||
user: item.user,
|
|
||||||
public: item.public,
|
|
||||||
inner_ip: item.inner_ip,
|
|
||||||
ischange: item.ischange,
|
|
||||||
isonline: item.isonline,
|
|
||||||
}))
|
|
||||||
|
|
||||||
setData(validatedData)
|
setTotalItems(result.totalCount || 0)
|
||||||
setSuccess(`成功查询到 ${validatedData.length} 条网关配置信息`)
|
}
|
||||||
} catch (error) {
|
catch (error) {
|
||||||
console.error('Failed to fetch gateway config:', error)
|
console.error('获取网关配置失败:', error)
|
||||||
setError(error instanceof Error ? error.message : '获取网关配置失败')
|
setError(error instanceof Error ? error.message : '获取网关配置失败')
|
||||||
setData([])
|
setData([])
|
||||||
} 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
|
||||||
@@ -159,17 +159,6 @@ 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 ? (
|
||||||
@@ -179,102 +168,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">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<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 className="flex flex-1 flex-col gap-4 mb-6">
|
||||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
|
<div className="bg-blue-50 p-4 rounded-lg border border-blue-100">
|
||||||
<div className="text-2xl font-bold text-blue-600">{data.length}</div>
|
<div className="text-2xl font-bold text-blue-600">{totalItems}</div>
|
||||||
<div className="text-sm text-blue-800">网关数量</div>
|
<div className="text-sm text-blue-800">配置条数</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-green-50 p-4 rounded-lg border border-green-100">
|
<div className="bg-green-50 p-4 rounded-lg border border-green-100">
|
||||||
<div className="text-2xl font-bold text-green-600">
|
<div className="text-2xl font-bold text-green-600">
|
||||||
{data.filter(item => item.isonline === 1).length}
|
{data.filter(item => item.isonline === 1).length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-green-800">在线网关</div>
|
<div className="text-sm text-green-800">在用节点</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100">
|
<div className="bg-orange-50 p-4 rounded-lg border border-orange-100">
|
||||||
<div className="text-2xl font-bold text-orange-600">
|
<div className="text-2xl font-bold text-orange-600">
|
||||||
{data.filter(item => item.ischange === 1).length}
|
{data.filter(item => item.ischange === 1).length}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-orange-800">已更新配置</div>
|
<div className="text-sm text-orange-800">需要更新</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
|
||||||
{new Set(data.map(item => item.city)).size}
|
|
||||||
</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,14 +255,14 @@ 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,41 +2,86 @@
|
|||||||
|
|
||||||
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'
|
||||||
|
import { getGatewayInfo, type GatewayInfo } from '@/actions/stats'
|
||||||
|
|
||||||
interface GatewayInfo {
|
const filterSchema = z.object({
|
||||||
macaddr: string
|
status: z.string(),
|
||||||
inner_ip: string
|
})
|
||||||
setid: string
|
|
||||||
enable: number
|
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 result = await getGatewayInfo()
|
||||||
|
|
||||||
if (!response.ok) {
|
const sortedData = result.data.sort((a: GatewayInfo, b: GatewayInfo) =>
|
||||||
throw new Error('获取网关信息失败')
|
sortByIpAddress(a.inner_ip, b.inner_ip),
|
||||||
|
)
|
||||||
|
setData(sortedData)
|
||||||
|
setFilteredData(sortedData) // 初始化时设置filteredData
|
||||||
}
|
}
|
||||||
|
catch (error) {
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
setData(result)
|
|
||||||
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +98,7 @@ export default function Gatewayinfo() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div className="bg-white shadow rounded-lg p-6 overflow-hidden">
|
||||||
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
<h2 className="text-lg font-semibold mb-4">网关基本信息</h2>
|
||||||
<div className="text-center py-8">加载网关信息中...</div>
|
<div className="text-center py-8">加载网关信息中...</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,10 +115,82 @@ 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 ">
|
||||||
|
<span className="text-lg pt-2 font-semibold mb-4">网关基本信息</span>
|
||||||
|
<Form {...form}>
|
||||||
|
<form className="flex items-center gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="status"
|
||||||
|
render={({ field }) => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="text-sm mr-2">状态筛选:</span>
|
||||||
|
<Select
|
||||||
|
value={field.value}
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue="1"
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-36">
|
||||||
|
<SelectValue placeholder="选择状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部</SelectItem>
|
||||||
|
<SelectItem value="1">启用</SelectItem>
|
||||||
|
<SelectItem value="0">禁用</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
<div className="flex gap-6 overflow-hidden">
|
||||||
|
<div className="flex-3 w-full flex">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-gray-50">
|
||||||
|
<TableHead className="px-4 py-2 text-left">MAC地址</TableHead>
|
||||||
|
<TableHead className="px-4 py-2 text-left">内网IP</TableHead>
|
||||||
|
<TableHead className="px-4 py-2 text-left">配置版本</TableHead>
|
||||||
|
<TableHead className="px-4 py-2 text-left">状态</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredData.map((item, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={index}
|
||||||
|
className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}
|
||||||
|
>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`)
|
||||||
|
}}
|
||||||
|
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
{item.macaddr}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">{item.inner_ip}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">{item.setid}</TableCell>
|
||||||
|
<TableCell className="px-4 py-2">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}>
|
||||||
|
{getStatusText(item.enable)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 mb-6">
|
||||||
<div className="bg-blue-50 p-4 rounded-lg">
|
<div className="bg-blue-50 p-4 rounded-lg">
|
||||||
<div className="text-2xl font-bold text-blue-600">{data.length}</div>
|
<div className="text-2xl font-bold text-blue-600">{data.length}</div>
|
||||||
<div className="text-sm text-blue-800">网关总数</div>
|
<div className="text-sm text-blue-800">网关总数</div>
|
||||||
@@ -90,48 +207,7 @@ export default function Gatewayinfo() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-red-800">禁用网关</div>
|
<div className="text-sm text-red-800">禁用网关</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-purple-50 p-4 rounded-lg">
|
|
||||||
<div className="text-2xl font-bold text-purple-600">
|
|
||||||
{new Set(data.map(item => item.setid)).size}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-purple-800">配置版本数</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full table-auto">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-gray-50">
|
|
||||||
<th className="px-4 py-2 text-left">MAC地址</th>
|
|
||||||
<th className="px-4 py-2 text-left">内网IP</th>
|
|
||||||
<th className="px-4 py-2 text-left">配置版本</th>
|
|
||||||
<th className="px-4 py-2 text-left">状态</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.map((item, index) => (
|
|
||||||
<tr key={index} className={index % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
router.push(`/dashboard?tab=gateway&mac=${item.macaddr}`);
|
|
||||||
}}
|
|
||||||
className="font-mono text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
|
|
||||||
>
|
|
||||||
{item.macaddr}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2">{item.inner_ip}</td>
|
|
||||||
<td className="px-4 py-2">{item.setid}</td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(item.enable)}`}>
|
|
||||||
{getStatusText(item.enable)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,32 +9,27 @@ 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,
|
import { findUsers, createUser, removeUser } from '@/actions/user'
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table"
|
|
||||||
|
|
||||||
// 用户类型定义
|
// 用户类型定义
|
||||||
interface UserData {
|
interface UserData {
|
||||||
id: string
|
id: number
|
||||||
account: string
|
account: string
|
||||||
createdAt: string
|
createdAt: Date
|
||||||
|
name: string | null
|
||||||
|
updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
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个字符'),
|
||||||
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,23 +45,18 @@ export default function Settings() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// 获取用户列表
|
// 获取用户列表
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/users')
|
const data = await findUsers()
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || '获取用户列表失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setUsers(data.users)
|
setUsers(data.data)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
toast.error("获取用户列表失败", {
|
catch (error) {
|
||||||
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
|
toast.error('获取用户列表失败', {
|
||||||
|
description: error instanceof Error ? error.message : '服务器连接失败,请稍后重试',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,79 +70,60 @@ export default function Settings() {
|
|||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/users', {
|
const data = await createUser({
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
account: values.account,
|
account: values.account,
|
||||||
password: values.password,
|
password: values.password,
|
||||||
}),
|
name: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || '创建用户失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
const data = await removeUser(userId)
|
||||||
const response = await fetch(`/api/users?id=${userId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(data.error || '删除用户失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
toast.success("用户删除成功", {
|
toast.success('用户删除成功', {
|
||||||
description: "用户账户已删除",
|
description: '用户账户已删除',
|
||||||
})
|
})
|
||||||
fetchUsers() // 刷新用户列表
|
fetchUsers() // 刷新用户列表
|
||||||
}
|
}
|
||||||
} catch (error) {
|
}
|
||||||
toast.error("删除用户失败", {
|
catch (error) {
|
||||||
description: error instanceof Error ? error.message : "服务器连接失败,请稍后重试",
|
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>
|
||||||
@@ -271,23 +242,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">
|
|
||||||
|
<div className="relative max-w-sm mt-4">
|
||||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="搜索用户..."
|
placeholder="搜索用户..."
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
<div className="border rounded-lg">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -299,23 +271,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,8 +296,8 @@ const handleDeleteUser = async (userId: number) => {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import AllocationStatus from './components/allocationStatus'
|
|||||||
import Settings from './components/settings'
|
import Settings from './components/settings'
|
||||||
import Edge from './components/edge'
|
import Edge from './components/edge'
|
||||||
import { LogOut } from 'lucide-react'
|
import { LogOut } from 'lucide-react'
|
||||||
|
import { logout } from '@/actions/auth'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'gatewayInfo', label: '网关信息' },
|
{ id: 'gatewayInfo', label: '网关信息' },
|
||||||
@@ -16,7 +18,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() {
|
||||||
@@ -37,20 +39,21 @@ function DashboardContent() {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/logout', {
|
const response = await logout()
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response) {
|
||||||
// 退出成功后跳转到登录页
|
// 退出成功后跳转到登录页
|
||||||
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,8 +67,8 @@ 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">
|
||||||
@@ -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,7 +107,7 @@ 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 />}
|
||||||
@@ -119,14 +122,14 @@ 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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -116,6 +116,7 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
{children}
|
||||||
<Toaster richColors />
|
<Toaster richColors />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export default function ErrorCard({
|
export default function ErrorCard({
|
||||||
title,
|
title,
|
||||||
error,
|
error,
|
||||||
onRetry
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
error: string
|
error: string
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,20 +1,3 @@
|
|||||||
// 数字格式化工具函数
|
|
||||||
export const formatNumber = (num: number | string): string => {
|
|
||||||
const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num
|
|
||||||
return numberValue.toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
export const formatLargeNumber = (num: number | string): string => {
|
|
||||||
const numberValue = typeof num === 'string' ? parseInt(num) || 0 : num
|
|
||||||
|
|
||||||
if (numberValue > 1e9) return `${(numberValue / 1e9).toFixed(1)}亿`
|
|
||||||
if (numberValue > 1e6) return `${(numberValue / 1e6).toFixed(1)}百万`
|
|
||||||
if (numberValue > 1e4) return `${(numberValue / 1e4).toFixed(1)}万`
|
|
||||||
if (numberValue > 1e3) return `${(numberValue / 1e3).toFixed(1)}千`
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
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()
|
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
|
||||||
|
export * from '@/generated/prisma/client'
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export const config = {
|
|||||||
|
|
||||||
const isIgnored = [
|
const isIgnored = [
|
||||||
'/login',
|
'/login',
|
||||||
"/api/auth/login"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function middleware(request: NextRequest) {
|
export async function middleware(request: NextRequest) {
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
},
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user