首页添加二级路由
This commit is contained in:
2
.env
2
.env
@@ -1,3 +1,3 @@
|
||||
VITE_API_BASE_URL=http://192.168.0.15:8080
|
||||
VITE_API_BASE_URL=http://192.168.3.42:8080
|
||||
VITE_CLIENT_ID=web
|
||||
VITE_CLIENT_SECRET=web
|
||||
@@ -17,7 +17,8 @@
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off"
|
||||
"useNodejsImportProtocol": "off",
|
||||
"noNonNullAssertion": "error"
|
||||
},
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
|
||||
21
bun.lock
21
bun.lock
@@ -6,19 +6,24 @@
|
||||
"name": "juip-web",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
@@ -129,6 +134,8 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.5.7", "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.5.7.tgz", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "@sinclair/typebox": ">=0.25.24", "@standard-schema/spec": "^1.0.0", "@typeschema/main": ">=0.13.7", "@vinejs/vine": "^2.0.0 || ^3.0.0", "ajv": "^8.12.0", "ajv-errors": "^3.0.0", "ajv-formats": "^2.1.1", "arktype": "^2.0.0", "ata-validator": "^0.7.0", "class-transformer": ">=0.4.0", "class-validator": ">=0.12.0", "computed-types": "^1.0.0", "effect": "^3.10.3", "fluentvalidation-ts": "^3.0.0", "fp-ts": "^2.7.0", "io-ts": "^2.0.0", "joi": "^17.0.0", "nope-validator": ">=0.12.0", "react-hook-form": "^7.55.0", "superstruct": ">=0.12.0", "typanion": "^3.3.2", "valibot": ">=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc", "vest": ">=3.0.0", "yup": "^1.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@sinclair/typebox", "@standard-schema/spec", "@typeschema/main", "@vinejs/vine", "ajv", "ajv-errors", "ajv-formats", "arktype", "ata-validator", "class-transformer", "class-validator", "computed-types", "effect", "fluentvalidation-ts", "fp-ts", "io-ts", "joi", "nope-validator", "superstruct", "typanion", "valibot", "vest", "yup", "zod"] }, "sha512-CyPCYV8/KlXfEXLWj8HHHhVsR/IZ6Ckm3b/a4fWtO/lRnRK1huqncb8LlAWrmpPRsse5glF5aVuDRMHEr3UGag=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -313,6 +320,8 @@
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "https://registry.npmmirror.com/@standard-schema/utils/-/utils-0.3.0.tgz", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.0.tgz", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.0.tgz", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||
@@ -701,6 +710,8 @@
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "https://registry.npmmirror.com/next-themes/-/next-themes-0.4.6.tgz", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
@@ -771,6 +782,8 @@
|
||||
|
||||
"react-dom": ["react-dom@19.2.7", "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.7.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="],
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.83.0", "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.83.0.tgz", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
@@ -831,6 +844,8 @@
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "https://registry.npmmirror.com/sisteransi/-/sisteransi-1.0.5.tgz", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"sonner": ["sonner@2.0.7", "https://registry.npmmirror.com/sonner/-/sonner-2.0.7.tgz", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||
|
||||
"source-map": ["source-map@0.6.1", "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -911,7 +926,7 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.2.tgz", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
"zod": ["zod@4.4.3", "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
@@ -919,6 +934,8 @@
|
||||
|
||||
"@dotenvx/dotenvx/execa": ["execa@5.1.1", "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
@@ -947,6 +964,8 @@
|
||||
|
||||
"restore-cursor/onetime": ["onetime@7.0.0", "https://registry.npmmirror.com/onetime/-/onetime-7.0.0.tgz", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"shadcn/zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
|
||||
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
@@ -10,19 +10,24 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@hookform/resolvers": "^5.5.7",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.5.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-hook-form": "^7.83.0",
|
||||
"react-router-dom": "^7.17.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
|
||||
@@ -1,5 +1,305 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { Link } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelRecord } from "@/lib/models/channel"
|
||||
|
||||
const filterSchema = z.object({
|
||||
expired_at_end: z.string().optional(),
|
||||
product: z.string().optional(),
|
||||
plan: z.string().optional(),
|
||||
account: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof filterSchema>
|
||||
|
||||
const initialData: ChannelRecord[] = [
|
||||
{
|
||||
id: "1",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "甘肃-兰州125.75.111.127",
|
||||
server: "p3.125.74.129.38.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "已过期",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "辽宁-辽阳123.190.161.192",
|
||||
server: "p16.cqdwx4.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "静态-项目-20M-月卡",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "黑龙江-鸡西100.100.4.249",
|
||||
server: "p37.sdlw2.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
]
|
||||
|
||||
export default function ChannelsPage() {
|
||||
return <Page>IP管理</Page>
|
||||
const [filterValues, setFilterValues] = useState<FormValues>({})
|
||||
const [status, setStatus] = useState<"load" | "done">("done")
|
||||
// 行选择状态
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||
resolver: zodResolver(filterSchema),
|
||||
defaultValues: {
|
||||
expired_at_end: "",
|
||||
product: "",
|
||||
plan: "",
|
||||
account: "",
|
||||
status: "",
|
||||
},
|
||||
})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!filterValues || Object.values(filterValues).every(value => !value)) {
|
||||
return initialData
|
||||
}
|
||||
|
||||
return initialData.filter(record => {
|
||||
if (filterValues.expired_at_end) {
|
||||
return record.expiredAt <= filterValues.expired_at_end
|
||||
}
|
||||
|
||||
if (
|
||||
filterValues.product &&
|
||||
!record.product.includes(filterValues.product)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filterValues.plan && !record.plan.includes(filterValues.plan)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (
|
||||
filterValues.account &&
|
||||
!record.account.includes(filterValues.account)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (filterValues.status && record.status !== filterValues.status) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}, [filterValues])
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
setStatus("load")
|
||||
setTimeout(() => {
|
||||
setFilterValues(values)
|
||||
setRowSelection({})
|
||||
setStatus("done")
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
reset()
|
||||
setFilterValues({})
|
||||
setRowSelection({})
|
||||
}
|
||||
|
||||
// 根据选中ID获取对应行数据
|
||||
const getSelectedRows = () => {
|
||||
const ids = Object.keys(rowSelection)
|
||||
return filteredData.filter(item => ids.includes(item.id))
|
||||
}
|
||||
|
||||
// 批量续费
|
||||
const handleRenew = () => {
|
||||
const rows = getSelectedRows()
|
||||
console.log("续费选中数据:", rows)
|
||||
}
|
||||
|
||||
// 单个退货
|
||||
const handleReturn = () => {
|
||||
const rows = getSelectedRows()
|
||||
if (rows.length !== 1) {
|
||||
toast.error("请选择一条数据进行退货")
|
||||
return
|
||||
}
|
||||
console.log("退货数据:", rows[0])
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div>
|
||||
<form className="bg-card rounded-lg" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex items-center justify-center gap-4 bg-amber-200 px-3 py-3">
|
||||
<span className="text-orange-600 text-sm">
|
||||
为了给您带来更好的服务体验,请完善QQ号和微信号
|
||||
</span>
|
||||
<Link to="/">立即完善</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap p-4 items-end gap-4">
|
||||
<Controller
|
||||
name="expired_at_end"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>到期时间</FieldLabel>
|
||||
<Input type="datetime-local" step="1" {...field} />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="product"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>全部产品</FieldLabel>
|
||||
<Input {...field} placeholder="请输入产品名称" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="plan"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-48 flex-none"
|
||||
>
|
||||
<FieldLabel>全部套餐</FieldLabel>
|
||||
<Input {...field} placeholder="请输入套餐名称" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="account"
|
||||
control={control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field
|
||||
data-invalid={fieldState.invalid}
|
||||
className="w-40 flex-none"
|
||||
>
|
||||
<FieldLabel>账号</FieldLabel>
|
||||
<Input {...field} placeholder="请输入账号" />
|
||||
<FieldError>{fieldState.error?.message}</FieldError>
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">查询</Button>
|
||||
<Button type="button" variant="outline" onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-lg p-4">
|
||||
<div className="flex gap-4 items-center">
|
||||
<Button type="button" variant="outline" onClick={handleRenew}>
|
||||
续费
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={handleReturn}>
|
||||
退货
|
||||
</Button>
|
||||
<p className="text-orange-600 text-sm">
|
||||
温馨提示:批量续费可通过筛选和搜索功能精准定位账号
|
||||
</p>
|
||||
</div>
|
||||
<DataTable<ChannelRecord>
|
||||
data={filteredData}
|
||||
status={status}
|
||||
columns={[
|
||||
{ accessorKey: "startAt", header: "开通时间" },
|
||||
{ accessorKey: "product", header: "产品" },
|
||||
{ accessorKey: "plan", header: "套餐" },
|
||||
{ accessorKey: "account", header: "账号" },
|
||||
{ accessorKey: "password", header: "密码" },
|
||||
{ accessorKey: "ip", header: "IP地址" },
|
||||
{ accessorKey: "server", header: "服务器" },
|
||||
{ accessorKey: "connections", header: "连接数" },
|
||||
{ accessorKey: "expiredAt", header: "到期时间" },
|
||||
{ accessorKey: "remaining", header: "剩余时间" },
|
||||
{
|
||||
accessorKey: "online",
|
||||
header: "在线及拨线",
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="outline">
|
||||
{row.original.online}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ accessorKey: "status", header: "选择地区" },
|
||||
{
|
||||
accessorKey: "download",
|
||||
header: "软件下载",
|
||||
cell: ({ row }) => (
|
||||
<Button size="sm" variant="ghost">
|
||||
{row.original.download}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
pagination={{
|
||||
page: 0,
|
||||
size: 10,
|
||||
total: filteredData.length,
|
||||
}}
|
||||
enableSelection
|
||||
getRowId={row => row.id}
|
||||
rowSelection={rowSelection}
|
||||
onSelectionChange={setRowSelection}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { cn } from "@/lib/utils"
|
||||
import logoAvatar from "./assets/logo-avatar.svg"
|
||||
import { useLayout } from "./layout-context"
|
||||
import { AdminNavMenu } from "./nav-menu"
|
||||
|
||||
export function Shell(props: { children: ReactNode }) {
|
||||
const { navbar } = useLayout()
|
||||
@@ -35,7 +36,7 @@ export function Shell(props: { children: ReactNode }) {
|
||||
data-expand={navbar}
|
||||
className={cn(
|
||||
"transition-[grid-template-columns] duration-300 ease-in-out",
|
||||
"w-full h-full grid",
|
||||
"w-full h-full grid bg-card",
|
||||
"grid-rows-[64px_1fr]",
|
||||
"data-[expand=true]:grid-cols-[200px_1fr]",
|
||||
"data-[expand=false]:grid-cols-[0px_1fr]",
|
||||
@@ -57,7 +58,7 @@ export function Content(props: { children: ReactNode }) {
|
||||
"absolute inset-0 overflow-hidden",
|
||||
"mt-16",
|
||||
"md:ml-16",
|
||||
"lg:data-[expand=true]:ml-[200px]",
|
||||
"lg:data-[expand=true]:ml-50",
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
@@ -85,8 +86,8 @@ export function Mask() {
|
||||
export function Header() {
|
||||
const { navbar, toggleNavbar } = useLayout()
|
||||
return (
|
||||
<header className="flex-none h-16 overflow-hidden flex items-stretch">
|
||||
<div className="flex-auto flex items-center gap-2">
|
||||
<header className="flex-none h-16 flex items-stretch">
|
||||
<div className="flex-none flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-9 ml-4 md:ml-0"
|
||||
@@ -96,13 +97,15 @@ export function Header() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<AdminNavMenu />
|
||||
|
||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||
<Link
|
||||
{/* <Link
|
||||
to="/"
|
||||
className="flex-none h-16 flex items-center justify-center text-sm"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</Link> */}
|
||||
<UserCenter />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -92,7 +92,7 @@ export default function DashboardPage() {
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const handleSaveInfo = (data: any) => {
|
||||
console.log("保存的信息:", data)
|
||||
// 这里调用你的 API 保存数据
|
||||
// 这里调 API 保存数据
|
||||
}
|
||||
return (
|
||||
<Page
|
||||
|
||||
@@ -32,17 +32,17 @@ export default function Effects(props: { children?: ReactNode }) {
|
||||
|
||||
setNavbar(md.matches)
|
||||
|
||||
sm.addEventListener("change", (e) => {
|
||||
sm.addEventListener("change", e => {
|
||||
breakpoints.sm = e.matches
|
||||
})
|
||||
md.addEventListener("change", (e) => {
|
||||
md.addEventListener("change", e => {
|
||||
breakpoints.md = e.matches
|
||||
setNavbar(e.matches)
|
||||
})
|
||||
lg.addEventListener("change", (e) => {
|
||||
lg.addEventListener("change", e => {
|
||||
breakpoints.lg = e.matches
|
||||
})
|
||||
xl.addEventListener("change", (e) => {
|
||||
xl.addEventListener("change", e => {
|
||||
breakpoints.xl = e.matches
|
||||
})
|
||||
|
||||
|
||||
@@ -9,11 +9,11 @@ export default function AdminLayout() {
|
||||
<Effects />
|
||||
<div className="relative h-dvh overflow-hidden">
|
||||
<Shell>
|
||||
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
|
||||
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20 flex">
|
||||
<Navbar />
|
||||
</div>
|
||||
|
||||
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
|
||||
<div className="col-start-2 row-start-1 bg-card relative z-20">
|
||||
<Header />
|
||||
</div>
|
||||
|
||||
|
||||
252
src/admin/nav-menu.tsx
Normal file
252
src/admin/nav-menu.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function AdminNavMenu() {
|
||||
return (
|
||||
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
|
||||
<AdminNavItem to="/">首页</AdminNavItem>
|
||||
<AdminMenuItem text="产品购买">
|
||||
<ProductDropdown />
|
||||
</AdminMenuItem>
|
||||
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
|
||||
<AdminNavItem to="/softDownload">软件下载</AdminNavItem>
|
||||
<AdminNavItem to="/help">帮助/教程</AdminNavItem>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminNavItem({
|
||||
to,
|
||||
children,
|
||||
}: {
|
||||
to: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"h-10 flex items-center px-3 text-sm rounded-md transition-colors duration-200",
|
||||
isActive
|
||||
? "text-blue-600 bg-blue-50"
|
||||
: "text-slate-600 hover:text-blue-600 hover:bg-gray-100",
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
function AdminMenuItem({
|
||||
text,
|
||||
children,
|
||||
}: {
|
||||
text: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect()
|
||||
setPos({ top: rect.bottom + 8, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
updatePosition()
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={triggerRef}
|
||||
className="relative h-full flex items-center"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"h-10 flex items-center gap-1 px-3 text-sm rounded-md transition-colors duration-200 pointer-events-none",
|
||||
isOpen
|
||||
? "text-blue-600 bg-blue-50"
|
||||
: "text-slate-600 group-hover:text-blue-600 group-hover:bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<span>{text}</span>
|
||||
<svg
|
||||
className={cn(
|
||||
"w-3 h-3 transition-transform duration-200",
|
||||
isOpen && "rotate-180",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{createPortal(
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
"fixed transition-all duration-200 z-[9999]",
|
||||
isOpen
|
||||
? "opacity-100 visible translate-y-0"
|
||||
: "opacity-0 invisible -translate-y-2 pointer-events-none",
|
||||
)}
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-4 w-[560px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductDropdown() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
navigate(path)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<ProductCard
|
||||
title="动静态IP"
|
||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
||||
tag="热门"
|
||||
onClick={() => handleClick("/product/short")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="新HTTP/S5"
|
||||
desc="IP可用时长从数小时到365天全覆盖"
|
||||
tag="稳定"
|
||||
onClick={() => handleClick("/product/long")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="HTTP/S5"
|
||||
desc="海量全球住宅优质IP,4G/5G真实手机IP"
|
||||
tag="全球"
|
||||
onClick={() => handleClick("/product/global")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="软路由成本价"
|
||||
desc="云端自动切换IP,更易于调用与开发"
|
||||
onClick={() => handleClick("/product/tunnel")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="动态Vps"
|
||||
desc="IP独享,高带宽,灵活控制存活周期"
|
||||
onClick={() => handleClick("/product/exclusive")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="静态独享IP"
|
||||
desc="纯净IP,更符合跨境卖家需求的云主机"
|
||||
onClick={() => handleClick("/product/static")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2 pt-2 border-t border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
|
||||
<button
|
||||
onClick={() => handleClick("/product/short")}
|
||||
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
短效代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClick("/product/global")}
|
||||
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
海外代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClick("/product/tunnel")}
|
||||
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
隧道代理
|
||||
</button>
|
||||
<span className="text-xs text-gray-300">|</span>
|
||||
<button
|
||||
onClick={() => handleClick("/custom")}
|
||||
className="text-xs text-orange-500 hover:underline font-medium whitespace-nowrap"
|
||||
>
|
||||
业务定制 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductCard({
|
||||
title,
|
||||
desc,
|
||||
tag,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
desc: string
|
||||
tag?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="group flex items-start gap-2 p-2 rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors text-xs whitespace-nowrap">
|
||||
{title}
|
||||
</span>
|
||||
{tag && (
|
||||
<span className="text-[10px] bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded-full shrink-0">
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5 leading-relaxed line-clamp-2">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { Loader } from "lucide-react"
|
||||
import { useMemo } from "react"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Pagination, type PaginationProps } from "@/components/ui/pagination"
|
||||
import {
|
||||
TableBody,
|
||||
@@ -25,29 +27,87 @@ export type DataTableProps<T> = {
|
||||
headRow?: string
|
||||
dataRow?: string
|
||||
}
|
||||
/** 是否开启行选择复选框 */
|
||||
enableSelection?: boolean
|
||||
/** 受控选中状态 */
|
||||
rowSelection?: Record<string, boolean>
|
||||
/** 选中状态变化回调 */
|
||||
onSelectionChange?: (selection: Record<string, boolean>) => void
|
||||
/** 行唯一ID生成函数 */
|
||||
getRowId?: (row: T, index: number) => string
|
||||
}
|
||||
|
||||
export default function DataTable<T extends Record<string, unknown>>(
|
||||
props: DataTableProps<T>,
|
||||
) {
|
||||
// 动态注入复选框列
|
||||
const finalColumns = useMemo<ColumnDef<T>[]>(() => {
|
||||
if (!props.enableSelection) return props.columns
|
||||
|
||||
const selectColumn: ColumnDef<T> = {
|
||||
id: "select",
|
||||
size: 40,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected()
|
||||
? true
|
||||
: table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="全选当前页"
|
||||
className="rounded-none translate-y-0.5"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={value => row.toggleSelected(!!value)}
|
||||
aria-label="选择行"
|
||||
className="rounded-none translate-y-0.5"
|
||||
/>
|
||||
),
|
||||
}
|
||||
|
||||
return [selectColumn, ...props.columns]
|
||||
}, [props.enableSelection, props.columns])
|
||||
|
||||
const table = useReactTable({
|
||||
data: props.data,
|
||||
columns: props.columns,
|
||||
columns: finalColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
rowCount: props.pagination?.total ?? props.data.length,
|
||||
// 行选择核心配置
|
||||
enableRowSelection: props.enableSelection ?? false,
|
||||
getRowId: props.getRowId,
|
||||
onRowSelectionChange: updaterOrValue => {
|
||||
if (!props.onSelectionChange) return
|
||||
|
||||
const newSelection =
|
||||
typeof updaterOrValue === "function"
|
||||
? updaterOrValue(props.rowSelection ?? {})
|
||||
: updaterOrValue
|
||||
|
||||
props.onSelectionChange(newSelection)
|
||||
},
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: props.pagination?.page ?? 0,
|
||||
pageSize: props.pagination?.size ?? props.data.length,
|
||||
},
|
||||
columnFilters: [],
|
||||
...(props.rowSelection !== undefined && {
|
||||
rowSelection: props.rowSelection,
|
||||
}),
|
||||
},
|
||||
autoResetPageIndex: false,
|
||||
})
|
||||
console.log("data length:", props.data.length)
|
||||
console.log("pageSize:", props.pagination?.size ?? props.data.length)
|
||||
console.log("rows:", table.getRowModel().rows.length)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 数据表 */}
|
||||
@@ -55,7 +115,7 @@ export default function DataTable<T extends Record<string, unknown>>(
|
||||
<TableRoot>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map(group => (
|
||||
<TableRow key={group.id}>
|
||||
<TableRow key={group.id} className={props.classNames?.headRow}>
|
||||
{group.headers.map(header => (
|
||||
<TableHead key={header.id} colSpan={header.colSpan}>
|
||||
{header.isPlaceholder
|
||||
@@ -73,7 +133,7 @@ export default function DataTable<T extends Record<string, unknown>>(
|
||||
{props.status === "fail" ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={props.columns.length}
|
||||
colSpan={finalColumns.length}
|
||||
className="text-center text-fail"
|
||||
>
|
||||
加载失败
|
||||
@@ -82,7 +142,7 @@ export default function DataTable<T extends Record<string, unknown>>(
|
||||
) : !props.data?.length ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={props.columns.length}
|
||||
colSpan={finalColumns.length}
|
||||
className="text-center"
|
||||
>
|
||||
暂无数据
|
||||
|
||||
235
src/components/ui/field.tsx
Normal file
235
src/components/ui/field.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { useMemo } from "react"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
||||
return (
|
||||
<fieldset
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<legend
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
|
||||
horizontal:
|
||||
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
responsive:
|
||||
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
|
||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return (
|
||||
<p
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
|
||||
"last:mt-0 nth-last-2:-mt-1",
|
||||
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<span
|
||||
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
const content = useMemo(() => {
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
|
||||
if (!errors?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const uniqueErrors = [
|
||||
...new Map(errors.map(error => [error?.message, error])).values(),
|
||||
]
|
||||
|
||||
if (uniqueErrors?.length === 1) {
|
||||
return uniqueErrors[0]?.message
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
||||
{uniqueErrors.map(
|
||||
(error, index) =>
|
||||
error?.message && <li key={index}>{error.message}</li>,
|
||||
)}
|
||||
</ul>
|
||||
)
|
||||
}, [children, errors])
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-sm font-normal text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
@@ -12,7 +12,7 @@ function Label({
|
||||
data-slot="label"
|
||||
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",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
28
src/components/ui/separator.tsx
Normal file
28
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
import type * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils/index"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
43
src/components/ui/sonner.tsx
Normal file
43
src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
import { NavLink } from "react-router-dom"
|
||||
import { useRef, useState, useSyncExternalStore } from "react"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -15,6 +15,14 @@ export default function Header() {
|
||||
() => false,
|
||||
)
|
||||
|
||||
// ======================
|
||||
// 菜单状态
|
||||
// ======================
|
||||
const [openMenu, setOpenMenu] = useState(false)
|
||||
|
||||
const handleEnter = () => setOpenMenu(true)
|
||||
const handleLeave = () => setOpenMenu(false)
|
||||
|
||||
return (
|
||||
<header className={cn("fixed top-0 left-0 w-screen z-10 flex flex-col")}>
|
||||
<div
|
||||
@@ -30,11 +38,17 @@ export default function Header() {
|
||||
<nav className="flex items-center justify-between gap-4 lg:gap-8">
|
||||
<ul className="h-full items-stretch hidden lg:flex">
|
||||
<NavItem to="/">首页</NavItem>
|
||||
<NavItem to="/product">产品购买</NavItem>
|
||||
<MenuItem
|
||||
text="产品购买"
|
||||
isOpen={openMenu}
|
||||
onMouseEnter={handleEnter}
|
||||
onMouseLeave={handleLeave}
|
||||
>
|
||||
<ProductDropdown onNavigate={() => setOpenMenu(false)} />
|
||||
</MenuItem>
|
||||
<NavItem to="/ipline">IP线路表</NavItem>
|
||||
<NavItem to="/softDownload">软件下载</NavItem>
|
||||
<NavItem to="/help">帮助/教程</NavItem>
|
||||
{/* <NavItem to="/client">下载客户端</NavItem> */}
|
||||
</ul>
|
||||
</nav>
|
||||
<ProfileOrLogin />
|
||||
@@ -53,15 +67,15 @@ const NavItem = ({
|
||||
}) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`h-full flex items-center relative px-3 ${
|
||||
isActive
|
||||
? "text-blue-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-blue-500"
|
||||
: "text-slate-700"
|
||||
}`
|
||||
}
|
||||
className="group h-full flex items-center relative px-3 text-slate-700 hover:text-blue-500 transition-colors duration-200"
|
||||
>
|
||||
{children}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-0.5 transition-colors duration-200",
|
||||
"bg-transparent group-hover:bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
</NavLink>
|
||||
)
|
||||
|
||||
@@ -89,3 +103,200 @@ function ProfileOrLogin() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MenuItem = ({
|
||||
text,
|
||||
children,
|
||||
isOpen,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
}: {
|
||||
text: string
|
||||
children: React.ReactNode
|
||||
isOpen: boolean
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
}) => {
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
onMouseEnter()
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
onMouseLeave()
|
||||
}, 150)
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className="group relative h-full flex items-center"
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<button
|
||||
className={cn(
|
||||
"h-full flex items-center gap-1 px-3 text-lg transition-colors duration-200",
|
||||
isOpen ? "text-blue-500" : "text-slate-700 hover:text-blue-500",
|
||||
)}
|
||||
>
|
||||
<span>{text}</span>
|
||||
<svg
|
||||
className={cn(
|
||||
"w-4 h-4 transition-transform duration-200",
|
||||
isOpen ? "rotate-180" : "",
|
||||
)}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
"absolute bottom-0 left-0 right-0 h-0.5 transition-colors duration-200",
|
||||
isOpen ? "bg-blue-500" : "bg-transparent group-hover:bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 pt-3 w-screen max-w-4xl transition-all duration-200",
|
||||
isOpen
|
||||
? "opacity-100 visible translate-y-0"
|
||||
: "opacity-0 invisible -translate-y-2 pointer-events-none",
|
||||
)}
|
||||
style={{
|
||||
left: "50%",
|
||||
transform: `translateX(-7%)`,
|
||||
}}
|
||||
>
|
||||
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
const ProductDropdown = ({ onNavigate }: { onNavigate: () => void }) => {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleClick = (path: string) => {
|
||||
navigate(path)
|
||||
onNavigate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<ProductCard
|
||||
title="动静态IP"
|
||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
||||
tag="热门"
|
||||
onClick={() => handleClick("/product/short")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="新HTTP/S5"
|
||||
desc="IP可用时长从数小时到365天全覆盖"
|
||||
tag="稳定"
|
||||
onClick={() => handleClick("/product/long")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="HTTP/S5"
|
||||
desc="海量全球住宅优质IP,4G/5G真实手机IP"
|
||||
tag="全球"
|
||||
onClick={() => handleClick("/product/global")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="软路由成本价"
|
||||
desc="云端自动切换IP,更易于调用与开发"
|
||||
onClick={() => handleClick("/product/tunnel")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="动态Vps"
|
||||
desc="IP独享,高带宽,灵活控制存活周期"
|
||||
onClick={() => handleClick("/product/exclusive")}
|
||||
/>
|
||||
<ProductCard
|
||||
title="静态独享IP"
|
||||
desc="纯净IP,更符合跨境卖家需求的云主机"
|
||||
onClick={() => handleClick("/product/static")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-3 pt-3 border-t border-gray-100">
|
||||
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
|
||||
<button
|
||||
onClick={() => handleClick("/product/short")}
|
||||
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
短效代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClick("/product/global")}
|
||||
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
海外代理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClick("/product/tunnel")}
|
||||
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
隧道代理
|
||||
</button>
|
||||
<span className="text-xs text-gray-300">|</span>
|
||||
<button
|
||||
onClick={() => handleClick("/custom")}
|
||||
className="text-sm text-orange-500 hover:underline font-medium whitespace-nowrap"
|
||||
>
|
||||
业务定制 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const ProductCard = ({
|
||||
title,
|
||||
desc,
|
||||
tag,
|
||||
onClick,
|
||||
}: {
|
||||
title: string
|
||||
desc: string
|
||||
tag?: string
|
||||
onClick: () => void
|
||||
}) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="group flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors text-sm whitespace-nowrap">
|
||||
{title}
|
||||
</span>
|
||||
{tag && (
|
||||
<span className="text-[10px] bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full shrink-0">
|
||||
{tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1 leading-relaxed line-clamp-2">
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -79,12 +79,12 @@ export default function HomePage() {
|
||||
<ProductTypeCard icon={s5} title="专属化产品定制" />
|
||||
<ProductTypeCard icon={s6} title="专业的服务团队" />
|
||||
</ul>
|
||||
<div className="flex justify-between items-center gap-10 flex-wrap mt-16 p-20 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white">
|
||||
<div className="flex max-md:flex-col justify-between items-center gap-6 md:gap-10 mt-10 lg:mt-16 p-6 md:p-12 lg:p-20 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-2xl font-bold mb-4">
|
||||
<h3 className="text-xl lg:text-2xl font-bold mb-4">
|
||||
全网产品最多,IP库最大
|
||||
</h3>
|
||||
<p className="text-gray-600 w-150 text-sm">
|
||||
<p className="text-gray-600 max-w-full lg:w-150 text-sm">
|
||||
旗下有极狐、极光、蘑菇、猎豹、麒麟、水滴、火狐、无极等优质动态独享、静态独享、单窗口IP产品
|
||||
</p>
|
||||
</div>
|
||||
@@ -92,15 +92,15 @@ export default function HomePage() {
|
||||
<img src={s7} alt="产品" className="w-20 h-20 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col pt-24 ">
|
||||
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
|
||||
<div className="flex flex-col pt-12 lg:pt-24 ">
|
||||
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
|
||||
海量地区,遍布全国的优质IP代理资源
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center text-sm">
|
||||
遍布全国的代理服务器节点为用户提供智能帮助
|
||||
</p>
|
||||
</div>
|
||||
<div className="pt-20">
|
||||
<div className="pt-10 lg:pt-20">
|
||||
<ul className="shadow-[0_0_20px_4px] shadow-blue-50 p-8 flex max-lg:flex-col">
|
||||
<li className="flex-1 flex flex-col items-center justify-center max-lg:mb-4 ">
|
||||
<p className="mt-9 max-lg:mt-2 text-3xl bg-linear-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2">
|
||||
@@ -129,8 +129,8 @@ export default function HomePage() {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pt-24 ">
|
||||
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
|
||||
<div className="flex flex-col pt-12 lg:pt-24 ">
|
||||
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
|
||||
满足您的多种业务需求
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center text-sm">
|
||||
@@ -138,12 +138,12 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex mt-20 items-center justify-center">
|
||||
<img src={s9} alt="图片" />
|
||||
<div className="flex mt-10 lg:mt-20 items-center justify-center">
|
||||
<img src={s9} alt="图片" className="max-w-full h-auto" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pt-24 ">
|
||||
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
|
||||
<div className="flex flex-col pt-12 lg:pt-24 ">
|
||||
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
|
||||
为您提供质量保障
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center text-sm">
|
||||
@@ -156,7 +156,9 @@ export default function HomePage() {
|
||||
<div className="flex w-full mb-5 bg-blue-50 items-center justify-center">
|
||||
<img src={s10} alt="售前咨询" className="p-6" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold mb-8 lg:mb-24">售前咨询</h2>
|
||||
<h2 className="text-xl lg:text-3xl font-bold mb-4 lg:mb-8">
|
||||
售前咨询
|
||||
</h2>
|
||||
<p className="text-xs text-gray-600">
|
||||
注册即享新人福利,专业的客户经理,多维度为您提供在线代理相关
|
||||
答疑
|
||||
@@ -166,7 +168,9 @@ export default function HomePage() {
|
||||
<div className="flex w-full mb-5 bg-blue-50 items-center justify-center">
|
||||
<img src={s11} alt="售中服务" className="p-6" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold mb-8 lg:mb-24">售中服务</h2>
|
||||
<h2 className="text-xl lg:text-3xl font-bold mb-4 lg:mb-8">
|
||||
售中服务
|
||||
</h2>
|
||||
<p className="text-xs text-gray-600">
|
||||
全国100万+动态IP代理资源免费测试,先测后买让您安心使用
|
||||
</p>
|
||||
@@ -175,15 +179,17 @@ export default function HomePage() {
|
||||
<div className="flex w-full mb-5 bg-blue-50 items-center justify-center">
|
||||
<img src={s12} alt="售后保障" className="p-6" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold mb-8 lg:mb-24">售后保障</h2>
|
||||
<h2 className="text-xl lg:text-3xl font-bold mb-4 lg:mb-8">
|
||||
售后保障
|
||||
</h2>
|
||||
<p className="text-xs text-gray-600">
|
||||
1V1专属售后答疑,技术团队7*24小时在线支持提供专属解决方案
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col pt-24 ">
|
||||
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
|
||||
<div className="flex flex-col pt-12 lg:pt-24 ">
|
||||
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
|
||||
为您推荐聚IP的产品
|
||||
</h2>
|
||||
<p className="text-gray-600 text-center text-sm">
|
||||
@@ -191,7 +197,7 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-30">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 pt-12 lg:pt-30">
|
||||
<div className="flex flex-col shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white p-8">
|
||||
<div className="mb-3">
|
||||
<h4 className="text-xl font-semibold mb-2 group-hover:text-blue-600 transition-colors">
|
||||
@@ -296,7 +302,9 @@ function PageSection(props: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="max-w-308 mx-auto px-4 flex flex-col items-stretch">
|
||||
<h2 className="text-center text-3xl mb-8 lg:mb-24">{props.title}</h2>
|
||||
<h2 className="text-center text-2xl lg:text-3xl mb-6 lg:mb-16">
|
||||
{props.title}
|
||||
</h2>
|
||||
{props.children}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
16
src/lib/models/channel.ts
Normal file
16
src/lib/models/channel.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type ChannelRecord = {
|
||||
id: string
|
||||
startAt: string
|
||||
product: string
|
||||
plan: string
|
||||
account: string
|
||||
password: string
|
||||
ip: string
|
||||
server: string
|
||||
connections: number
|
||||
expiredAt: string
|
||||
remaining: string
|
||||
online: string
|
||||
download: string
|
||||
status: string
|
||||
}
|
||||
Reference in New Issue
Block a user