diff --git a/.env b/.env index 415afbc..9746e73 100644 --- a/.env +++ b/.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 \ No newline at end of file diff --git a/biome.json b/biome.json index 274332c..6d595e4 100644 --- a/biome.json +++ b/biome.json @@ -17,7 +17,8 @@ "rules": { "recommended": true, "style": { - "useNodejsImportProtocol": "off" + "useNodejsImportProtocol": "off", + "noNonNullAssertion": "error" }, "a11y": { "useButtonType": "off", diff --git a/bun.lock b/bun.lock index e6aeef8..e53ff7d 100644 --- a/bun.lock +++ b/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=="], diff --git a/package.json b/package.json index b4da69b..dcc9f36 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/admin/channels/page.tsx b/src/admin/channels/page.tsx index bfb0580..a2e9122 100644 --- a/src/admin/channels/page.tsx +++ b/src/admin/channels/page.tsx @@ -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 + +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 IP管理 + const [filterValues, setFilterValues] = useState({}) + const [status, setStatus] = useState<"load" | "done">("done") + // 行选择状态 + const [rowSelection, setRowSelection] = useState>({}) + + const { control, handleSubmit, reset } = useForm({ + 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 ( + +
+
+
+ + 为了给您带来更好的服务体验,请完善QQ号和微信号 + + 立即完善 +
+
+ ( + + 到期时间 + + {fieldState.error?.message} + + )} + /> + + ( + + 全部产品 + + {fieldState.error?.message} + + )} + /> + + ( + + 全部套餐 + + {fieldState.error?.message} + + )} + /> + + ( + + 账号 + + {fieldState.error?.message} + + )} + /> + +
+ + +
+
+
+
+ +
+
+ + +

+ 温馨提示:批量续费可通过筛选和搜索功能精准定位账号 +

+
+ + 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 }) => ( + + ), + }, + { accessorKey: "status", header: "选择地区" }, + { + accessorKey: "download", + header: "软件下载", + cell: ({ row }) => ( + + ), + }, + ]} + pagination={{ + page: 0, + size: 10, + total: filteredData.length, + }} + enableSelection + getRowId={row => row.id} + rowSelection={rowSelection} + onSelectionChange={setRowSelection} + /> +
+
+ ) } diff --git a/src/admin/clients.tsx b/src/admin/clients.tsx index 9541ab4..1083f26 100644 --- a/src/admin/clients.tsx +++ b/src/admin/clients.tsx @@ -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 ( -
-
+
+
+ +
- 返回首页 - + */}
diff --git a/src/admin/dashboard/page.tsx b/src/admin/dashboard/page.tsx index b0432dc..3f589d4 100644 --- a/src/admin/dashboard/page.tsx +++ b/src/admin/dashboard/page.tsx @@ -92,7 +92,7 @@ export default function DashboardPage() { const [editDialogOpen, setEditDialogOpen] = useState(false) const handleSaveInfo = (data: any) => { console.log("保存的信息:", data) - // 这里调用你的 API 保存数据 + // 这里调 API 保存数据 } return ( { + 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 }) diff --git a/src/admin/layout.tsx b/src/admin/layout.tsx index 92cf199..f63c607 100644 --- a/src/admin/layout.tsx +++ b/src/admin/layout.tsx @@ -9,11 +9,11 @@ export default function AdminLayout() {
-
+
-
+
diff --git a/src/admin/nav-menu.tsx b/src/admin/nav-menu.tsx new file mode 100644 index 0000000..30654d5 --- /dev/null +++ b/src/admin/nav-menu.tsx @@ -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 ( + + ) +} + +function AdminNavItem({ + to, + children, +}: { + to: string + children: React.ReactNode +}) { + return ( + + 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} + + ) +} + +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(null) + const timeoutRef = useRef(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 ( +
+ + + {createPortal( +
+
+ {children} +
+
, + document.body, + )} +
+ ) +} + +function ProductDropdown() { + const navigate = useNavigate() + + const handleClick = (path: string) => { + navigate(path) + } + + return ( +
+
+ handleClick("/product/short")} + /> + handleClick("/product/long")} + /> + handleClick("/product/global")} + /> + handleClick("/product/tunnel")} + /> + handleClick("/product/exclusive")} + /> + handleClick("/product/static")} + /> +
+ +
+ 热门推荐: + + + + | + +
+
+ ) +} + +function ProductCard({ + title, + desc, + tag, + onClick, +}: { + title: string + desc: string + tag?: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/src/components/data-table.tsx b/src/components/data-table.tsx index d62a085..60e7e1d 100644 --- a/src/components/data-table.tsx +++ b/src/components/data-table.tsx @@ -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 = { headRow?: string dataRow?: string } + /** 是否开启行选择复选框 */ + enableSelection?: boolean + /** 受控选中状态 */ + rowSelection?: Record + /** 选中状态变化回调 */ + onSelectionChange?: (selection: Record) => void + /** 行唯一ID生成函数 */ + getRowId?: (row: T, index: number) => string } export default function DataTable>( props: DataTableProps, ) { + // 动态注入复选框列 + const finalColumns = useMemo[]>(() => { + if (!props.enableSelection) return props.columns + + const selectColumn: ColumnDef = { + id: "select", + size: 40, + enableSorting: false, + enableHiding: false, + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="全选当前页" + className="rounded-none translate-y-0.5" + /> + ), + cell: ({ row }) => ( + 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>( {table.getHeaderGroups().map(group => ( - + {group.headers.map(header => ( {header.isPlaceholder @@ -73,7 +133,7 @@ export default function DataTable>( {props.status === "fail" ? ( 加载失败 @@ -82,7 +142,7 @@ export default function DataTable>( ) : !props.data?.length ? ( 暂无数据 diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx new file mode 100644 index 0000000..287000e --- /dev/null +++ b/src/components/ui/field.tsx @@ -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 ( +
[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 ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +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) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +