修复动静态ip标题跳转问题 & 添加admin页面

This commit is contained in:
Eamon
2026-08-18 13:33:22 +08:00
parent 1336a550fe
commit fc034dc971
35 changed files with 2516 additions and 524 deletions

1
.env
View File

@@ -1,3 +1,4 @@
VITE_API_BASE_URL= VITE_API_BASE_URL=
VITE_PHP_API_BASE_URL=https://php-api.juip.com
VITE_CLIENT_ID=web VITE_CLIENT_ID=web
VITE_CLIENT_SECRET=web VITE_CLIENT_SECRET=web

View File

@@ -12,6 +12,7 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.5.0", "radix-ui": "^1.5.0",
@@ -547,6 +548,8 @@
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"date-fns": ["date-fns@4.4.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],

View File

@@ -16,6 +16,7 @@
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.17.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"radix-ui": "^1.5.0", "radix-ui": "^1.5.0",

View File

@@ -156,13 +156,13 @@ export function Navbar() {
expand={navbar} expand={navbar}
/> />
<NavItem <NavItem
href="/admin/channels" href="/admin/orders"
icon={<ShoppingBag size={20} />} icon={<ShoppingBag size={20} />}
label="订单管理" label="订单管理"
expand={navbar} expand={navbar}
/> />
<NavItem <NavItem
href="/admin/channels" href="/admin/refundOrders"
icon={<ClipboardList size={20} />} icon={<ClipboardList size={20} />}
label="退货管理" label="退货管理"
expand={navbar} expand={navbar}
@@ -180,31 +180,31 @@ export function Navbar() {
expand={navbar} expand={navbar}
/> />
<NavItem <NavItem
href="/admin/record" href="/admin/httpRecharge"
icon={<Archive size={20} />} icon={<Archive size={20} />}
label="充值记录" label="充值记录"
expand={navbar} expand={navbar}
/> />
<NavItem <NavItem
href="/admin/resources_short" href="/admin/resources/short"
icon={<Clock1 size={20} />} icon={<Clock1 size={20} />}
label="短效套餐" label="短效套餐"
expand={navbar} expand={navbar}
/> />
<NavItem <NavItem
href="/admin/resources_long" href="/admin/resources/long"
icon={<CalendarClock size={20} />} icon={<CalendarClock size={20} />}
label="长效管理" label="长效管理"
expand={navbar} expand={navbar}
/> />
<NavItem <NavItem
href="/admin/channels" href="/admin/rosorder"
icon={<Router size={20} />} icon={<Router size={20} />}
label="软路由" label="软路由"
expand={navbar} expand={navbar}
/> />
<NavItem <NavItem
href="/admin/channels" href="/admin/record"
icon={<ClipboardClock size={20} />} icon={<ClipboardClock size={20} />}
label="使用记录" label="使用记录"
expand={navbar} expand={navbar}

View File

@@ -1,5 +1,5 @@
import { useState } from "react" import { useState } from "react"
import { EditInfoDialog } from "@/components/composites/dialogs/editInfoDialog" import { EditInfoDialog, type EditInfoValues } from "@/components/composites/dialogs/editInfoDialog"
import DataTable from "@/components/data-table" import DataTable from "@/components/data-table"
import Page from "@/components/page" import Page from "@/components/page"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -90,7 +90,7 @@ export default function DashboardPage() {
yearConsumption: 2365, yearConsumption: 2365,
} }
const [editDialogOpen, setEditDialogOpen] = useState(false) const [editDialogOpen, setEditDialogOpen] = useState(false)
const handleSaveInfo = (data: any) => { const handleSaveInfo = (data: EditInfoValues) => {
console.log("保存的信息:", data) console.log("保存的信息:", data)
// 这里调 API 保存数据 // 这里调 API 保存数据
} }
@@ -300,11 +300,14 @@ export default function DashboardPage() {
</div> </div>
{/* 账号信息 */} {/* 账号信息 */}
<div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col"> <div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col min-h-0">
<h3 className="font-semibold text-lg shrink-0"></h3> <h3 className="font-semibold text-lg shrink-0"></h3>
<div className="mt-4 flex justify-between items-start gap-3 flex-auto"> <div className="mt-4 flex flex-col sm:flex-row justify-between items-start gap-4 flex-1 min-h-0">
<div className="flex flex-col items-center gap-4 shrink-0"> {/* 头像/二维码区域 */}
<span className="text-base font-medium">{accountInfo.phone}</span> <div className="flex flex-col items-center gap-3 shrink-0 w-full sm:w-auto">
<span className="text-base font-medium truncate w-full text-center sm:text-left">
{accountInfo.phone}
</span>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -312,49 +315,51 @@ export default function DashboardPage() {
> >
</Button> </Button>
<div className="w-20 h-20 border border-gray-200 rounded"> <div className="w-20 h-20 border border-gray-200 rounded shrink-0 overflow-hidden">
<img <img
src="/qrcode-placeholder.png" src="/qrcode-placeholder.png"
alt="账号二维码" alt="账号二维码"
className="w-full h-full object-contain" className="w-full h-full object-cover"
/> />
</div> </div>
</div> </div>
<div className="flex flex-col gap-2.5 text-sm flex-1"> {/* 信息列表 */}
<div className="flex items-start py-1 border-b border-gray-50 last:border-0"> <div className="flex flex-col gap-2 text-sm flex-1 min-w-0 w-full">
<span className="text-gray-400 w-20 shrink-0"></span> <div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-700 font-mono tracking-wide"> <span className="text-gray-400 w-14 shrink-0"></span>
<span className="text-gray-700 font-mono tracking-wide truncate">
******** ********
</span> </span>
</div> </div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0"> <div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-20 shrink-0">QQ</span> <span className="text-gray-400 w-14 shrink-0">QQ</span>
<span className="text-gray-700"> <span className="text-gray-700 truncate">
{accountInfo.qq || "未绑定"} {accountInfo.qq || "未绑定"}
</span> </span>
</div> </div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0"> <div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-20 shrink-0"></span> <span className="text-gray-400 w-14 shrink-0"></span>
<span className="text-gray-700"> <span className="text-gray-700 truncate">
{accountInfo.wechat || "未绑定"} {accountInfo.wechat || "未绑定"}
</span> </span>
</div> </div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0"> <div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-20 shrink-0"></span> <span className="text-gray-400 w-14 shrink-0"></span>
<span className="text-gray-700"> <span className="text-gray-700 truncate">
{accountInfo.taobao || "未绑定"} {accountInfo.taobao || "未绑定"}
</span> </span>
</div> </div>
<div className="flex items-start py-1 border-b border-gray-50 last:border-0"> <div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
<span className="text-gray-400 w-20 shrink-0"></span> <span className="text-gray-400 w-14 shrink-0"></span>
<span className="text-gray-700 truncate"> <span className="text-gray-700 truncate">
{accountInfo.email || "未绑定"} {accountInfo.email || "未绑定"}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
<div className="mt-5 flex justify-end shrink-0">
<div className="mt-4 flex justify-end shrink-0">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@@ -1,5 +1,29 @@
import DataTable from "@/components/data-table"
import Page from "@/components/page" import Page from "@/components/page"
export default function FundsPage() { export default function FundsPage() {
return <Page></Page> return (
<Page>
<DataTable
data={[]}
status="done"
columns={[
{ accessorKey: "date", header: "交易时间" },
{ accessorKey: "product", header: "用户" },
{ accessorKey: "plan", header: "资金去向" },
{
accessorKey: "price",
header: "金额",
},
{ accessorKey: "connections", header: "操作前约(元)" },
{ accessorKey: "account", header: "备注" },
]}
pagination={{
page: 0,
size: 10,
total: 0,
}}
/>
</Page>
)
} }

View File

@@ -0,0 +1,32 @@
import DataTable from "@/components/data-table"
import Page from "@/components/page"
export default function HttpRechargePage() {
return (
<Page>
<h3 className="font-semibold text-lg">HTTP </h3>
<DataTable
data={[]}
status="done"
columns={[
{
accessorKey: "create_time",
header: "交易时间",
},
{
accessorKey: "ju_money",
header: "充值H币",
},
{
accessorKey: "pay_type",
header: "充值渠道",
},
{
accessorKey: "pay_money",
header: "充值金额",
},
]}
/>
</Page>
)
}

401
src/admin/orders/mock.ts Normal file
View File

@@ -0,0 +1,401 @@
import type { OrderItem } from "@/lib/models/order"
export const MOCK_ORDERS: OrderItem[] = [
{
Id: 23,
OrderNo: "JU202508150001",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "天卡",
DayPrice: 3,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1234",
OrderAmount: 3,
CouponAmount: 0,
PaymentAmount: 3,
CreateTime: "2025-08-15 10:23:45",
UpdateTime: "2025-08-15 10:24:01",
},
{
Id: 22,
OrderNo: "JU202508150002",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "周卡",
DayPrice: 18,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1235",
OrderAmount: 18,
CouponAmount: 2,
PaymentAmount: 16,
CreateTime: "2025-08-15 09:12:33",
UpdateTime: "2025-08-15 09:12:50",
},
{
Id: 21,
OrderNo: "JU202508140003",
OrderType: 2,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "月卡",
DayPrice: 68,
ConnectCount: 2,
AccountCount: 1,
Accounts: "jx1236",
OrderAmount: 68,
CouponAmount: 0,
PaymentAmount: 68,
CreateTime: "2025-08-14 16:45:21",
UpdateTime: "2025-08-14 16:45:39",
},
{
Id: 20,
OrderNo: "JU202508140004",
OrderType: 3,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "月卡(活动)",
DayPrice: 88,
ConnectCount: 5,
AccountCount: 3,
Accounts: "https-001,https-002,https-003",
OrderAmount: 264,
CouponAmount: 10,
PaymentAmount: 254,
CreateTime: "2025-08-14 14:02:17",
UpdateTime: "2025-08-14 14:02:55",
},
{
Id: 19,
OrderNo: "JU202508140005",
OrderType: 1,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "季卡",
DayPrice: 168,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-001",
OrderAmount: 168,
CouponAmount: 0,
PaymentAmount: 168,
CreateTime: "2025-08-14 11:30:08",
UpdateTime: "2025-08-14 11:30:26",
},
{
Id: 18,
OrderNo: "JU202508130006",
OrderType: 4,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "年卡",
DayPrice: 688,
ConnectCount: 10,
AccountCount: 5,
Accounts: "batch-jx-01,batch-jx-02,batch-jx-03,batch-jx-04,batch-jx-05",
OrderAmount: 3440,
CouponAmount: 100,
PaymentAmount: 3340,
CreateTime: "2025-08-13 20:15:44",
UpdateTime: "2025-08-13 20:16:02",
},
{
Id: 17,
OrderNo: "JU202508130007",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "测试卡",
DayPrice: 0.5,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1237",
OrderAmount: 0.5,
CouponAmount: 0,
PaymentAmount: 0.5,
CreateTime: "2025-08-13 18:03:12",
UpdateTime: "2025-08-13 18:03:28",
},
{
Id: 16,
OrderNo: "JU202508120008",
OrderType: 2,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "双月卡(活动)",
DayPrice: 128,
ConnectCount: 3,
AccountCount: 1,
Accounts: "https-004",
OrderAmount: 128,
CouponAmount: 0,
PaymentAmount: 128,
CreateTime: "2025-08-12 15:22:37",
UpdateTime: "2025-08-12 15:22:59",
},
{
Id: 15,
OrderNo: "JU202508120009",
OrderType: 1,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "月卡",
DayPrice: 98,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-002",
OrderAmount: 98,
CouponAmount: 5,
PaymentAmount: 93,
CreateTime: "2025-08-12 13:40:55",
UpdateTime: "2025-08-12 13:41:13",
},
{
Id: 14,
OrderNo: "JU202508110010",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "天卡",
DayPrice: 3,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1238",
OrderAmount: 3,
CouponAmount: 0,
PaymentAmount: 3,
CreateTime: "2025-08-11 10:05:29",
UpdateTime: "2025-08-11 10:05:44",
},
{
Id: 13,
OrderNo: "JU202508110011",
OrderType: 3,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "周卡",
DayPrice: 18,
ConnectCount: 4,
AccountCount: 4,
Accounts: "batch-jx-06,batch-jx-07,batch-jx-08,batch-jx-09",
OrderAmount: 72,
CouponAmount: 0,
PaymentAmount: 72,
CreateTime: "2025-08-11 08:56:48",
UpdateTime: "2025-08-11 08:57:06",
},
{
Id: 12,
OrderNo: "JU202508100012",
OrderType: 2,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "季卡",
DayPrice: 168,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1239",
OrderAmount: 168,
CouponAmount: 0,
PaymentAmount: 168,
CreateTime: "2025-08-10 19:33:20",
UpdateTime: "2025-08-10 19:33:41",
},
{
Id: 11,
OrderNo: "JU202508090013",
OrderType: 1,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "年卡",
DayPrice: 880,
ConnectCount: 20,
AccountCount: 2,
Accounts: "https-005,https-006",
OrderAmount: 1760,
CouponAmount: 50,
PaymentAmount: 1710,
CreateTime: "2025-08-09 21:18:07",
UpdateTime: "2025-08-09 21:18:30",
},
{
Id: 10,
OrderNo: "JU202508090014",
OrderType: 1,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "测试卡",
DayPrice: 1,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-003",
OrderAmount: 1,
CouponAmount: 0,
PaymentAmount: 1,
CreateTime: "2025-08-09 17:47:52",
UpdateTime: "2025-08-09 17:48:09",
},
{
Id: 9,
OrderNo: "JU202508080015",
OrderType: 4,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "月卡(活动)",
DayPrice: 88,
ConnectCount: 6,
AccountCount: 6,
Accounts: "https-b1,https-b2,https-b3,https-b4,https-b5,https-b6",
OrderAmount: 528,
CouponAmount: 20,
PaymentAmount: 508,
CreateTime: "2025-08-08 12:26:34",
UpdateTime: "2025-08-08 12:26:58",
},
{
Id: 8,
OrderNo: "JU202508070016",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "月卡(活动)",
DayPrice: 58,
ConnectCount: 2,
AccountCount: 1,
Accounts: "jx1240",
OrderAmount: 58,
CouponAmount: 0,
PaymentAmount: 58,
CreateTime: "2025-08-07 09:11:26",
UpdateTime: "2025-08-07 09:11:43",
},
{
Id: 7,
OrderNo: "JU202508060017",
OrderType: 2,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "季卡",
DayPrice: 268,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-004",
OrderAmount: 268,
CouponAmount: 8,
PaymentAmount: 260,
CreateTime: "2025-08-06 22:05:11",
UpdateTime: "2025-08-06 22:05:35",
},
{
Id: 6,
OrderNo: "JU202508050018",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "天卡",
DayPrice: 3,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1241",
OrderAmount: 3,
CouponAmount: 0,
PaymentAmount: 3,
CreateTime: "2025-08-05 14:38:49",
UpdateTime: "2025-08-05 14:39:05",
},
{
Id: 5,
OrderNo: "JU202508040019",
OrderType: 3,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "月卡",
DayPrice: 98,
ConnectCount: 3,
AccountCount: 3,
Accounts: "global-b1,global-b2,global-b3",
OrderAmount: 294,
CouponAmount: 0,
PaymentAmount: 294,
CreateTime: "2025-08-04 10:52:18",
UpdateTime: "2025-08-04 10:52:37",
},
{
Id: 4,
OrderNo: "JU202508030020",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "双月卡(活动)",
DayPrice: 98,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1242",
OrderAmount: 98,
CouponAmount: 0,
PaymentAmount: 98,
CreateTime: "2025-08-03 16:07:23",
UpdateTime: "2025-08-03 16:07:44",
},
{
Id: 3,
OrderNo: "JU202508020021",
OrderType: 2,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "年卡",
DayPrice: 688,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1243",
OrderAmount: 688,
CouponAmount: 30,
PaymentAmount: 658,
CreateTime: "2025-08-02 20:29:56",
UpdateTime: "2025-08-02 20:30:14",
},
{
Id: 2,
OrderNo: "JU202508010022",
OrderType: 1,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "周卡",
DayPrice: 25,
ConnectCount: 2,
AccountCount: 1,
Accounts: "https-007",
OrderAmount: 25,
CouponAmount: 0,
PaymentAmount: 25,
CreateTime: "2025-08-01 11:44:02",
UpdateTime: "2025-08-01 11:44:21",
},
{
Id: 1,
OrderNo: "JU202507310023",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "月卡",
DayPrice: 68,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1244",
OrderAmount: 68,
CouponAmount: 0,
PaymentAmount: 68,
CreateTime: "2025-07-31 09:58:37",
UpdateTime: "2025-07-31 09:58:55",
},
]
export const MOCK_PRODUCTS = [
{ id: 1, name: "动静态IP" },
{ id: 2, name: "新HTTP/S5" },
{ id: 3, name: "HTTP/S5" },
]

290
src/admin/orders/page.tsx Normal file
View File

@@ -0,0 +1,290 @@
import type { ColumnDef } from "@tanstack/react-table"
import { SearchIcon } from "lucide-react"
import { useMemo, useState } from "react"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
cutAccount,
DEFAULT_FILTERS,
formatOrderType,
ORDER_TYPE_LABELS,
type OrderFilters,
type OrderItem,
PACKAGE_OPTIONS,
totalConnections,
} from "@/lib/models/order"
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
function formatMoney(value: number): string {
return value.toFixed(2)
}
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}.${month}.${day}`
}
function AccountCell({ accounts }: { accounts: string }) {
const isCut = accounts.length > 15
if (!isCut) return <span className="truncate">{accounts}</span>
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
</Tooltip>
)
}
const columns: ColumnDef<OrderItem>[] = [
{
accessorKey: "CreateTime",
header: "日期",
cell: ({ row }) => formatDate(row.original.CreateTime),
},
{
accessorKey: "OrderNo",
header: "订单编号",
},
{
accessorKey: "OrderType",
header: "类型",
cell: ({ row }) => formatOrderType(row.original.OrderType),
},
{
accessorKey: "ProductName",
header: "产品",
},
{
accessorKey: "PackageName",
header: "套餐",
},
{
accessorKey: "DayPrice",
header: "单价",
cell: ({ row }) => formatMoney(row.original.DayPrice),
},
{
accessorKey: "ConnectCount",
header: "总连接数",
cell: ({ row }) => totalConnections(row.original),
},
{
accessorKey: "Accounts",
header: "账号",
cell: ({ row }) => <AccountCell accounts={row.original.Accounts} />,
},
{
accessorKey: "OrderAmount",
header: "订单金额",
cell: ({ row }) => formatMoney(row.original.OrderAmount),
},
{
accessorKey: "CouponAmount",
header: "优惠券",
cell: ({ row }) => formatMoney(row.original.CouponAmount),
},
{
accessorKey: "PaymentAmount",
header: "实付金额",
cell: ({ row }) => formatMoney(row.original.PaymentAmount),
},
]
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
const keyword = filters.keyword.trim()
return list.filter(order => {
if (
filters.orderType !== "0" &&
order.OrderType !== Number(filters.orderType)
) {
return false
}
if (
filters.productId !== "0" &&
order.ProductId !== Number(filters.productId)
) {
return false
}
if (
filters.packageName !== "0" &&
order.PackageName !== filters.packageName
) {
return false
}
if (
keyword &&
!order.OrderNo.includes(keyword) &&
!order.Accounts.includes(keyword)
) {
return false
}
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
return false
}
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
return false
}
return true
})
}
export default function OrdersPage() {
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
const [appliedFilters, setAppliedFilters] =
useState<OrderFilters>(DEFAULT_FILTERS)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const filtered = useMemo(
() => matchOrders(MOCK_ORDERS, appliedFilters),
[appliedFilters],
)
const paged = useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
)
const patch = (partial: Partial<OrderFilters>) =>
setFilters({ ...filters, ...partial })
const handleSearch = () => {
setAppliedFilters(filters)
setPage(1)
}
return (
<Page>
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 whitespace-nowrap">
</span>
<Input
type="date"
value={filters.bTime}
onChange={e => patch({ bTime: e.target.value })}
className="w-36"
aria-label="开始时间"
/>
<span className="text-sm text-gray-400"></span>
<Input
type="date"
value={filters.eTime}
onChange={e => patch({ eTime: e.target.value })}
className="w-36"
aria-label="结束时间"
/>
</div>
<Select
value={filters.orderType}
onValueChange={value => patch({ orderType: value })}
>
<SelectTrigger className="w-30">
<SelectValue placeholder="全部类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
{Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.productId}
onValueChange={value => patch({ productId: value })}
>
<SelectTrigger className="w-32">
<SelectValue placeholder="全部产品" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
{MOCK_PRODUCTS.map(product => (
<SelectItem key={product.id} value={String(product.id)}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.packageName}
onValueChange={value => patch({ packageName: value })}
>
<SelectTrigger className="w-32">
<SelectValue placeholder="全部套餐" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
{PACKAGE_OPTIONS.map(pkg => (
<SelectItem key={pkg} value={pkg}>
{pkg}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Input
value={filters.keyword}
onChange={e => patch({ keyword: e.target.value })}
onKeyDown={e => {
if (e.key === "Enter") handleSearch()
}}
placeholder="账号"
className="w-40"
/>
<Button onClick={handleSearch}>
<SearchIcon data-icon="inline-start" />
</Button>
</div>
</div>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
<h3 className="font-semibold text-lg"></h3>
<DataTable
data={paged}
status="done"
columns={columns}
pagination={{
page: page - 1,
size: pageSize,
total: filtered.length,
onPageChange: setPage,
onSizeChange: size => {
setPageSize(size)
setPage(1)
},
}}
/>
</div>
</Page>
)
}

View File

@@ -1,3 +0,0 @@
export default function LongPage() {
return <div>LongPage</div>
}

View File

@@ -1,3 +0,0 @@
export default function ShortPage() {
return <div>ShortPage</div>
}

40
src/admin/record/mock.ts Normal file
View File

@@ -0,0 +1,40 @@
import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
export const MOCK_USED_SUMMARY: HttpUsedSummary = {
todayShortPackIpUsed: 3,
shortPackIpUsed: 45,
}
const PACK_IP_PREFIX = [
{ packType: 21, prefix: "211.90.13" },
{ packType: 22, prefix: "112.65.28" },
]
function pad(value: number): string {
return String(value).padStart(2, "0")
}
function buildLogs(count: number): HttpUsedLogItem[] {
const now = new Date()
const logs: HttpUsedLogItem[] = []
for (let i = 0; i < count; i += 1) {
const source = PACK_IP_PREFIX[i % PACK_IP_PREFIX.length]
const time = new Date(now.getTime() - i * 37 * 60 * 1000)
const createTime =
`${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ` +
`${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`
logs.push({
packType: source.packType,
userIp: `113.118.${(i % 200) + 10}.${(i % 250) + 3}`,
ip: `${source.prefix}.${(i % 240) + 10}`,
port: 6000 + (i % 300),
createTime,
})
}
return logs
}
export const MOCK_USED_LOGS = buildLogs(56)

136
src/admin/record/page.tsx Normal file
View File

@@ -0,0 +1,136 @@
import type { ColumnDef } from "@tanstack/react-table"
import { useMemo, useState } from "react"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
formatPackType,
HTTP_USE_PACK_TYPE_OPTIONS,
type HttpUsedLogItem,
} from "@/lib/models/record"
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
const columns: ColumnDef<HttpUsedLogItem>[] = [
{
accessorKey: "packType",
header: "使用类型",
cell: ({ row }) => formatPackType(row.original.packType),
},
{
accessorKey: "userIp",
header: "客户端IP",
},
{
accessorKey: "ip",
header: "使用IP",
},
{
accessorKey: "port",
header: "端口号",
},
{
accessorKey: "createTime",
header: "使用时间",
},
]
function StatCard(props: {
label: string
value: number
accent: "amber" | "blue"
}) {
return (
<div
className={`rounded-lg p-4 flex flex-col gap-1 ${
props.accent === "amber" ? "bg-amber-100" : "bg-blue-100"
}`}
>
<span className="text-sm text-gray-600">{props.label}</span>
<span className="text-2xl font-semibold">{props.value} </span>
</div>
)
}
export default function RecordPage() {
const [packType, setPackType] = useState(HTTP_USE_PACK_TYPE_OPTIONS[0])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const filtered = useMemo(
() => MOCK_USED_LOGS.filter(log => log.packType === packType),
[packType],
)
const paged = useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
)
const handlePackTypeChange = (value: string) => {
setPackType(Number(value))
setPage(1)
}
return (
<Page>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
<h3 className="font-semibold text-lg">使</h3>
<div className="grid grid-cols-4 sm:grid-cols-4 gap-4">
<StatCard
label="当日使用IP数量"
value={MOCK_USED_SUMMARY.todayShortPackIpUsed}
accent="amber"
/>
<StatCard
label="累计使用IP数量"
value={MOCK_USED_SUMMARY.shortPackIpUsed}
accent="blue"
/>
</div>
</div>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
<div className="flex flex-col gap-4">
<p className="text-sm text-orange-600">
72
</p>
<Select value={String(packType)} onValueChange={handlePackTypeChange}>
<SelectTrigger className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_USE_PACK_TYPE_OPTIONS.map(type => (
<SelectItem key={type} value={String(type)}>
{formatPackType(type)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DataTable
data={paged}
status="done"
columns={columns}
pagination={{
page,
size: pageSize,
total: filtered.length,
onPageChange: setPage,
onSizeChange: size => {
setPageSize(size)
setPage(1)
},
}}
/>
</div>
</Page>
)
}

View File

@@ -0,0 +1,401 @@
import type { OrderItem } from "@/lib/models/order"
export const MOCK_ORDERS: OrderItem[] = [
{
Id: 23,
OrderNo: "JU202508150001",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "天卡",
DayPrice: 3,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1234",
OrderAmount: 3,
CouponAmount: 0,
PaymentAmount: 3,
CreateTime: "2025-08-15 10:23:45",
UpdateTime: "2025-08-15 10:24:01",
},
{
Id: 22,
OrderNo: "JU202508150002",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "周卡",
DayPrice: 18,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1235",
OrderAmount: 18,
CouponAmount: 2,
PaymentAmount: 16,
CreateTime: "2025-08-15 09:12:33",
UpdateTime: "2025-08-15 09:12:50",
},
{
Id: 21,
OrderNo: "JU202508140003",
OrderType: 2,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "月卡",
DayPrice: 68,
ConnectCount: 2,
AccountCount: 1,
Accounts: "jx1236",
OrderAmount: 68,
CouponAmount: 0,
PaymentAmount: 68,
CreateTime: "2025-08-14 16:45:21",
UpdateTime: "2025-08-14 16:45:39",
},
{
Id: 20,
OrderNo: "JU202508140004",
OrderType: 3,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "月卡(活动)",
DayPrice: 88,
ConnectCount: 5,
AccountCount: 3,
Accounts: "https-001,https-002,https-003",
OrderAmount: 264,
CouponAmount: 10,
PaymentAmount: 254,
CreateTime: "2025-08-14 14:02:17",
UpdateTime: "2025-08-14 14:02:55",
},
{
Id: 19,
OrderNo: "JU202508140005",
OrderType: 1,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "季卡",
DayPrice: 168,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-001",
OrderAmount: 168,
CouponAmount: 0,
PaymentAmount: 168,
CreateTime: "2025-08-14 11:30:08",
UpdateTime: "2025-08-14 11:30:26",
},
{
Id: 18,
OrderNo: "JU202508130006",
OrderType: 4,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "年卡",
DayPrice: 688,
ConnectCount: 10,
AccountCount: 5,
Accounts: "batch-jx-01,batch-jx-02,batch-jx-03,batch-jx-04,batch-jx-05",
OrderAmount: 3440,
CouponAmount: 100,
PaymentAmount: 3340,
CreateTime: "2025-08-13 20:15:44",
UpdateTime: "2025-08-13 20:16:02",
},
{
Id: 17,
OrderNo: "JU202508130007",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "测试卡",
DayPrice: 0.5,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1237",
OrderAmount: 0.5,
CouponAmount: 0,
PaymentAmount: 0.5,
CreateTime: "2025-08-13 18:03:12",
UpdateTime: "2025-08-13 18:03:28",
},
{
Id: 16,
OrderNo: "JU202508120008",
OrderType: 2,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "双月卡(活动)",
DayPrice: 128,
ConnectCount: 3,
AccountCount: 1,
Accounts: "https-004",
OrderAmount: 128,
CouponAmount: 0,
PaymentAmount: 128,
CreateTime: "2025-08-12 15:22:37",
UpdateTime: "2025-08-12 15:22:59",
},
{
Id: 15,
OrderNo: "JU202508120009",
OrderType: 1,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "月卡",
DayPrice: 98,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-002",
OrderAmount: 98,
CouponAmount: 5,
PaymentAmount: 93,
CreateTime: "2025-08-12 13:40:55",
UpdateTime: "2025-08-12 13:41:13",
},
{
Id: 14,
OrderNo: "JU202508110010",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "天卡",
DayPrice: 3,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1238",
OrderAmount: 3,
CouponAmount: 0,
PaymentAmount: 3,
CreateTime: "2025-08-11 10:05:29",
UpdateTime: "2025-08-11 10:05:44",
},
{
Id: 13,
OrderNo: "JU202508110011",
OrderType: 3,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "周卡",
DayPrice: 18,
ConnectCount: 4,
AccountCount: 4,
Accounts: "batch-jx-06,batch-jx-07,batch-jx-08,batch-jx-09",
OrderAmount: 72,
CouponAmount: 0,
PaymentAmount: 72,
CreateTime: "2025-08-11 08:56:48",
UpdateTime: "2025-08-11 08:57:06",
},
{
Id: 12,
OrderNo: "JU202508100012",
OrderType: 2,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "季卡",
DayPrice: 168,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1239",
OrderAmount: 168,
CouponAmount: 0,
PaymentAmount: 168,
CreateTime: "2025-08-10 19:33:20",
UpdateTime: "2025-08-10 19:33:41",
},
{
Id: 11,
OrderNo: "JU202508090013",
OrderType: 1,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "年卡",
DayPrice: 880,
ConnectCount: 20,
AccountCount: 2,
Accounts: "https-005,https-006",
OrderAmount: 1760,
CouponAmount: 50,
PaymentAmount: 1710,
CreateTime: "2025-08-09 21:18:07",
UpdateTime: "2025-08-09 21:18:30",
},
{
Id: 10,
OrderNo: "JU202508090014",
OrderType: 1,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "测试卡",
DayPrice: 1,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-003",
OrderAmount: 1,
CouponAmount: 0,
PaymentAmount: 1,
CreateTime: "2025-08-09 17:47:52",
UpdateTime: "2025-08-09 17:48:09",
},
{
Id: 9,
OrderNo: "JU202508080015",
OrderType: 4,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "月卡(活动)",
DayPrice: 88,
ConnectCount: 6,
AccountCount: 6,
Accounts: "https-b1,https-b2,https-b3,https-b4,https-b5,https-b6",
OrderAmount: 528,
CouponAmount: 20,
PaymentAmount: 508,
CreateTime: "2025-08-08 12:26:34",
UpdateTime: "2025-08-08 12:26:58",
},
{
Id: 8,
OrderNo: "JU202508070016",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "月卡(活动)",
DayPrice: 58,
ConnectCount: 2,
AccountCount: 1,
Accounts: "jx1240",
OrderAmount: 58,
CouponAmount: 0,
PaymentAmount: 58,
CreateTime: "2025-08-07 09:11:26",
UpdateTime: "2025-08-07 09:11:43",
},
{
Id: 7,
OrderNo: "JU202508060017",
OrderType: 2,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "季卡",
DayPrice: 268,
ConnectCount: 1,
AccountCount: 1,
Accounts: "global-004",
OrderAmount: 268,
CouponAmount: 8,
PaymentAmount: 260,
CreateTime: "2025-08-06 22:05:11",
UpdateTime: "2025-08-06 22:05:35",
},
{
Id: 6,
OrderNo: "JU202508050018",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "天卡",
DayPrice: 3,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1241",
OrderAmount: 3,
CouponAmount: 0,
PaymentAmount: 3,
CreateTime: "2025-08-05 14:38:49",
UpdateTime: "2025-08-05 14:39:05",
},
{
Id: 5,
OrderNo: "JU202508040019",
OrderType: 3,
ProductId: 3,
ProductName: "HTTP/S5",
PackageName: "月卡",
DayPrice: 98,
ConnectCount: 3,
AccountCount: 3,
Accounts: "global-b1,global-b2,global-b3",
OrderAmount: 294,
CouponAmount: 0,
PaymentAmount: 294,
CreateTime: "2025-08-04 10:52:18",
UpdateTime: "2025-08-04 10:52:37",
},
{
Id: 4,
OrderNo: "JU202508030020",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "双月卡(活动)",
DayPrice: 98,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1242",
OrderAmount: 98,
CouponAmount: 0,
PaymentAmount: 98,
CreateTime: "2025-08-03 16:07:23",
UpdateTime: "2025-08-03 16:07:44",
},
{
Id: 3,
OrderNo: "JU202508020021",
OrderType: 2,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "年卡",
DayPrice: 688,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1243",
OrderAmount: 688,
CouponAmount: 30,
PaymentAmount: 658,
CreateTime: "2025-08-02 20:29:56",
UpdateTime: "2025-08-02 20:30:14",
},
{
Id: 2,
OrderNo: "JU202508010022",
OrderType: 1,
ProductId: 2,
ProductName: "新HTTP/S5",
PackageName: "周卡",
DayPrice: 25,
ConnectCount: 2,
AccountCount: 1,
Accounts: "https-007",
OrderAmount: 25,
CouponAmount: 0,
PaymentAmount: 25,
CreateTime: "2025-08-01 11:44:02",
UpdateTime: "2025-08-01 11:44:21",
},
{
Id: 1,
OrderNo: "JU202507310023",
OrderType: 1,
ProductId: 1,
ProductName: "动静态IP",
PackageName: "月卡",
DayPrice: 68,
ConnectCount: 1,
AccountCount: 1,
Accounts: "jx1244",
OrderAmount: 68,
CouponAmount: 0,
PaymentAmount: 68,
CreateTime: "2025-07-31 09:58:37",
UpdateTime: "2025-07-31 09:58:55",
},
]
export const MOCK_PRODUCTS = [
{ id: 1, name: "动静态IP" },
{ id: 2, name: "新HTTP/S5" },
{ id: 3, name: "HTTP/S5" },
]

View File

@@ -0,0 +1,272 @@
import type { ColumnDef } from "@tanstack/react-table"
import { SearchIcon } from "lucide-react"
import { useMemo, useState } from "react"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
cutAccount,
DEFAULT_FILTERS,
formatOrderType,
type OrderFilters,
type OrderItem,
PACKAGE_OPTIONS,
totalConnections,
} from "@/lib/models/order"
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
function formatMoney(value: number): string {
return value.toFixed(2)
}
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}.${month}.${day}`
}
function AccountCell({ accounts }: { accounts: string }) {
const isCut = accounts.length > 15
if (!isCut) return <span className="truncate">{accounts}</span>
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
</Tooltip>
)
}
const columns: ColumnDef<OrderItem>[] = [
{
accessorKey: "CreateTime",
header: "日期",
cell: ({ row }) => formatDate(row.original.CreateTime),
},
{
accessorKey: "OrderNo",
header: "订单编号",
},
{
accessorKey: "OrderType",
header: "类型",
cell: ({ row }) => formatOrderType(row.original.OrderType),
},
{
accessorKey: "ProductName",
header: "产品",
},
{
accessorKey: "PackageName",
header: "套餐",
},
{
accessorKey: "Accounts",
header: "账号",
cell: ({ row }) => <AccountCell accounts={row.original.Accounts} />,
},
{
accessorKey: "ConnectCount",
header: "连接数",
cell: ({ row }) => totalConnections(row.original),
},
{
accessorKey: "DayPrice",
header: "退款时长",
cell: ({ row }) => formatMoney(row.original.DayPrice),
},
{
accessorKey: "DayPrice",
header: "退款单价",
cell: ({ row }) => formatMoney(row.original.DayPrice),
},
{
accessorKey: "PaymentAmount",
header: "实付金额",
cell: ({ row }) => formatMoney(row.original.PaymentAmount),
},
{
accessorKey: "CouponAmount",
header: "退款金额",
cell: ({ row }) => formatMoney(row.original.CouponAmount),
},
]
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
const keyword = filters.keyword.trim()
return list.filter(order => {
if (
filters.orderType !== "0" &&
order.OrderType !== Number(filters.orderType)
) {
return false
}
if (
filters.productId !== "0" &&
order.ProductId !== Number(filters.productId)
) {
return false
}
if (
filters.packageName !== "0" &&
order.PackageName !== filters.packageName
) {
return false
}
if (
keyword &&
!order.OrderNo.includes(keyword) &&
!order.Accounts.includes(keyword)
) {
return false
}
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
return false
}
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
return false
}
return true
})
}
export default function RefundOrdersPage() {
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
const [appliedFilters, setAppliedFilters] =
useState<OrderFilters>(DEFAULT_FILTERS)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const filtered = useMemo(
() => matchOrders(MOCK_ORDERS, appliedFilters),
[appliedFilters],
)
const paged = useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
)
const patch = (partial: Partial<OrderFilters>) =>
setFilters({ ...filters, ...partial })
const handleSearch = () => {
setAppliedFilters(filters)
setPage(1)
}
return (
<Page>
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500 whitespace-nowrap">
</span>
<Input
type="date"
value={filters.bTime}
onChange={e => patch({ bTime: e.target.value })}
className="w-36"
aria-label="开始时间"
/>
<span className="text-sm text-gray-400"></span>
<Input
type="date"
value={filters.eTime}
onChange={e => patch({ eTime: e.target.value })}
className="w-36"
aria-label="结束时间"
/>
</div>
<Select
value={filters.productId}
onValueChange={value => patch({ productId: value })}
>
<SelectTrigger className="w-32">
<SelectValue placeholder="全部产品" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
{MOCK_PRODUCTS.map(product => (
<SelectItem key={product.id} value={String(product.id)}>
{product.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={filters.packageName}
onValueChange={value => patch({ packageName: value })}
>
<SelectTrigger className="w-32">
<SelectValue placeholder="全部套餐" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
{PACKAGE_OPTIONS.map(pkg => (
<SelectItem key={pkg} value={pkg}>
{pkg}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Input
value={filters.keyword}
onChange={e => patch({ keyword: e.target.value })}
onKeyDown={e => {
if (e.key === "Enter") handleSearch()
}}
placeholder="账号"
className="w-40"
/>
<Button onClick={handleSearch}>
<SearchIcon data-icon="inline-start" />
</Button>
</div>
</div>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
<h3 className="font-semibold text-lg"></h3>
<DataTable
data={paged}
status="done"
columns={columns}
pagination={{
page: page - 1,
size: pageSize,
total: filtered.length,
onPageChange: setPage,
onSizeChange: size => {
setPageSize(size)
setPage(1)
},
}}
/>
</div>
</Page>
)
}

View File

@@ -0,0 +1,5 @@
import Page from "@/components/page"
export default function LongPage() {
return <Page></Page>
}

View File

@@ -0,0 +1,5 @@
import Page from "@/components/page"
export default function ShortPage() {
return <Page></Page>
}

View File

@@ -0,0 +1,5 @@
import Page from "@/components/page"
export default function RosorderPage() {
return <Page></Page>
}

View File

@@ -1,5 +1,9 @@
import type { ApiResponse } from "@/lib/api" import type { ApiResponse } from "@/lib/api"
import { callPublic } from "./base"
export const PHP_API_BASE_URL =
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
const DEFAULT_TIMEOUT = 30_000
export type LineItem = { export type LineItem = {
city: string | number city: string | number
@@ -20,24 +24,50 @@ export type LineSearchResult = {
data: LineItem[] data: LineItem[]
} }
export async function fetchLineData( async function getJson<T>(url: string): Promise<ApiResponse<T>> {
product: number, const controller = new AbortController()
): Promise<ApiResponse<LineData>> { const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
return await callPublic<LineData>( try {
`/script/linedata/display.php?product=${product}`, const response = await fetch(url, { signal: controller.signal })
undefined, if (!response.ok) {
"GET", return {
success: false,
status: response.status,
message: "请求失败",
}
}
const data = (await response.json()) as T
return { success: true, data }
} catch (e) {
if (controller.signal.aborted) {
return {
success: false,
status: 408,
message: "请求超时,请稍后重试",
}
}
return {
success: false,
status: 500,
message: (e as Error).message || "网络错误",
}
} finally {
clearTimeout(timer)
}
}
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
return getJson<LineData>(
`${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`,
) )
} }
export async function searchLineData( export function searchLineData(
productid: number, productid: number,
info: string, info: string,
): Promise<ApiResponse<LineSearchResult>> { ): Promise<ApiResponse<LineSearchResult>> {
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}` const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
return await callPublic<LineSearchResult>( return getJson<LineSearchResult>(
`/script/linedata/search.php?${query}`, `${PHP_API_BASE_URL}/script/linedata/search.php?${query}`,
undefined,
"GET",
) )
} }

View File

@@ -9,3 +9,23 @@ export async function fetchProducts(): Promise<ApiResponse<ProductItem[]>> {
"GET", "GET",
) )
} }
export async function fetchStaticProducts(): Promise<
ApiResponse<ProductItem[]>
> {
return await callPublic<ProductItem[]>(
"/product/ApiProductStatic",
undefined,
"GET",
)
}
export async function fetchWindowProducts(): Promise<
ApiResponse<ProductItem[]>
> {
return await callPublic<ProductItem[]>(
"/product/ApiProductWindow",
undefined,
"GET",
)
}

View File

@@ -1,5 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Eye, EyeOff } from "lucide-react" import { Eye, EyeOff } from "lucide-react"
import { useState } from "react" import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -7,45 +10,70 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
export type EditInfoValues = {
username: string
taobao: string
password: string
email: string
wechat: string
qq: string
}
const editInfoSchema = z.object({
username: z.string().trim().min(1, "请输入用户名"),
taobao: z.string(),
password: z.string().min(1, "请输入密码"),
email: z
.string()
.refine(
v => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
"请输入正确的邮箱",
),
wechat: z.string(),
qq: z.string(),
})
interface EditInfoDialogProps { interface EditInfoDialogProps {
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
initialData?: { initialData?: EditInfoValues
username: string onSave?: (data: EditInfoValues) => void
taobao: string }
password: string
email: string const DEFAULT_INITIAL_DATA: EditInfoValues = {
wechat: string username: "191****1234",
qq: string taobao: "191****1234",
} password: "********",
onSave?: (data: any) => void email: "123456123@qq.com",
wechat: "",
qq: "",
} }
export function EditInfoDialog({ export function EditInfoDialog({
open, open,
onOpenChange, onOpenChange,
initialData = { initialData = DEFAULT_INITIAL_DATA,
username: "191****1234",
taobao: "191****1234",
password: "********",
email: "123456123@qq.com",
wechat: "",
qq: "",
},
onSave, onSave,
}: EditInfoDialogProps) { }: EditInfoDialogProps) {
const [formData, setFormData] = useState(initialData)
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const handleChange = (field: string, value: string) => { const form = useForm<EditInfoValues>({
setFormData(prev => ({ ...prev, [field]: value })) resolver: zodResolver(editInfoSchema),
} defaultValues: initialData,
})
const handleSubmit = () => { useEffect(() => {
onSave?.(formData) if (open) {
form.reset(initialData)
}
}, [open, initialData, form])
const onSubmit = (data: EditInfoValues) => {
onSave?.(data)
onOpenChange(false) onOpenChange(false)
} }
@@ -58,91 +86,123 @@ export function EditInfoDialog({
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 mt-2"> <form className="space-y-4 mt-2" onSubmit={form.handleSubmit(onSubmit)}>
{/* 用户名 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm text-gray-600"></Label> <Label className="text-sm text-gray-600"></Label>
<Input <Controller
value={formData.username} name="username"
onChange={e => handleChange("username", e.target.value)} control={form.control}
className="h-11 bg-gray-50 border-gray-200" render={({ field, fieldState }) => (
<>
<Input
{...field}
className="h-11 bg-gray-50 border-gray-200"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/> />
</div> </div>
{/* 淘宝会员名 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm text-gray-600"></Label> <Label className="text-sm text-gray-600"></Label>
<Input <Controller
value={formData.taobao} name="taobao"
onChange={e => handleChange("taobao", e.target.value)} control={form.control}
className="h-11 bg-gray-50 border-gray-200" render={({ field }) => (
<Input
{...field}
className="h-11 bg-gray-50 border-gray-200"
/>
)}
/> />
</div> </div>
{/* 密码 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm text-gray-600"></Label> <Label className="text-sm text-gray-600"></Label>
<div className="relative"> <Controller
<Input name="password"
type={showPassword ? "text" : "password"} control={form.control}
value={formData.password} render={({ field, fieldState }) => (
onChange={e => handleChange("password", e.target.value)} <>
className="h-11 bg-gray-50 border-gray-200 pr-10" <div className="relative">
/> <Input
<button {...field}
type="button" type={showPassword ? "text" : "password"}
onClick={() => setShowPassword(!showPassword)} className="h-11 bg-gray-50 border-gray-200 pr-10"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" />
> <button
{showPassword ? ( type="button"
<EyeOff className="h-4 w-4" /> onClick={() => setShowPassword(!showPassword)}
) : ( className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
<Eye className="h-4 w-4" /> >
)} {showPassword ? (
</button> <EyeOff className="h-4 w-4" />
</div> ) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div> </div>
{/* 邮箱 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm text-gray-600"></Label> <Label className="text-sm text-gray-600"></Label>
<Input <Controller
value={formData.email} name="email"
onChange={e => handleChange("email", e.target.value)} control={form.control}
className="h-11 bg-gray-50 border-gray-200" render={({ field, fieldState }) => (
<>
<Input
{...field}
className="h-11 bg-gray-50 border-gray-200"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/> />
</div> </div>
{/* 微信 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm text-gray-600"></Label> <Label className="text-sm text-gray-600"></Label>
<Input <Controller
value={formData.wechat} name="wechat"
onChange={e => handleChange("wechat", e.target.value)} control={form.control}
placeholder="请输入" render={({ field }) => (
className="h-11 bg-gray-50 border-gray-200" <Input
{...field}
placeholder="请输入"
className="h-11 bg-gray-50 border-gray-200"
/>
)}
/> />
</div> </div>
{/* QQ */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-sm text-gray-600">QQ</Label> <Label className="text-sm text-gray-600">QQ</Label>
<Input <Controller
value={formData.qq} name="qq"
onChange={e => handleChange("qq", e.target.value)} control={form.control}
placeholder="请输入" render={({ field }) => (
className="h-11 bg-gray-50 border-gray-200" <Input
{...field}
placeholder="请输入"
className="h-11 bg-gray-50 border-gray-200"
/>
)}
/> />
</div> </div>
{/* 保存按钮 */}
<Button <Button
onClick={handleSubmit} type="submit"
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium" className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
> >
</Button> </Button>
</div> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )

View File

@@ -27,14 +27,12 @@ export type DataTableProps<T> = {
headRow?: string headRow?: string
dataRow?: string dataRow?: string
} }
/** 是否开启行选择复选框 */
enableSelection?: boolean enableSelection?: boolean
/** 受控选中状态 */
rowSelection?: Record<string, boolean> rowSelection?: Record<string, boolean>
/** 选中状态变化回调 */
onSelectionChange?: (selection: Record<string, boolean>) => void onSelectionChange?: (selection: Record<string, boolean>) => void
/** 行唯一ID生成函数 */
getRowId?: (row: T, index: number) => string getRowId?: (row: T, index: number) => string
stickyHeader?: boolean
maxHeight?: string
} }
export default function DataTable<T extends Record<string, unknown>>( export default function DataTable<T extends Record<string, unknown>>(
@@ -112,8 +110,18 @@ export default function DataTable<T extends Record<string, unknown>>(
<> <>
{/* 数据表 */} {/* 数据表 */}
<div className="rounded-md relative bg-card"> <div className="rounded-md relative bg-card">
<TableRoot> <TableRoot
<TableHeader> containerClassName={
props.stickyHeader
? cn("overflow-y-auto", props.maxHeight ?? "max-h-[600px]")
: undefined
}
>
<TableHeader
className={
props.stickyHeader ? "sticky top-0 z-10 bg-card" : undefined
}
>
{table.getHeaderGroups().map(group => ( {table.getHeaderGroups().map(group => (
<TableRow key={group.id} className={props.classNames?.headRow}> <TableRow key={group.id} className={props.classNames?.headRow}>
{group.headers.map(header => ( {group.headers.map(header => (

View File

@@ -49,7 +49,7 @@ export default function ProductDropdown({
title="动静态IP" title="动静态IP"
desc="千万级动态短效IP自动去重高并发采集" desc="千万级动态短效IP自动去重高并发采集"
tag="热门" tag="热门"
onClick={() => handleClick("/product?tab=short")} onClick={() => handleClick("/product")}
/> />
<ProductCard <ProductCard
size={s} size={s}
@@ -87,7 +87,7 @@ export default function ProductDropdown({
> >
<span className="text-xs font-medium text-gray-400">:</span> <span className="text-xs font-medium text-gray-400">:</span>
<button <button
onClick={() => handleClick("/product?tab=short")} onClick={() => handleClick("/product")}
className={cn( className={cn(
"text-blue-600 hover:underline whitespace-nowrap", "text-blue-600 hover:underline whitespace-nowrap",
s.footerLink, s.footerLink,

View File

@@ -2,11 +2,15 @@ import type * as React from "react"
import { cn } from "@/lib/utils/index" import { cn } from "@/lib/utils/index"
function Table({ className, ...props }: React.ComponentProps<"table">) { function Table({
className,
containerClassName,
...props
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
return ( return (
<div <div
data-slot="table-container" data-slot="table-container"
className="relative w-full overflow-x-auto" className={cn("relative w-full overflow-x-auto", containerClassName)}
> >
<table <table
data-slot="table" data-slot="table"

View File

@@ -1,20 +1,37 @@
import { useCallback, useEffect, useMemo, useState } from "react" import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Toaster, toast } from "sonner" import { Toaster, toast } from "sonner"
import { fetchLineData, type LineItem, searchLineData } from "@/api/linedata" import {
import { fetchProducts } from "@/api/product" fetchLineData,
type LineItem,
PHP_API_BASE_URL,
searchLineData,
} from "@/api/linedata"
import DataTable from "@/components/data-table"
import { Button } from "@/components/ui/button"
import Wrap from "@/components/wrap" import Wrap from "@/components/wrap"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const REFRESH_INTERVAL = 5 * 60 * 1000 const REFRESH_INTERVAL = 5 * 60 * 1000
const PAGE_SIZE_OPTIONS = [100, 200, 500]
type ProductOption = { id: number; name: string } type ProductOption = {
id: number
type DisplayRow = { name: string
item: LineItem l2tp: string
nameSpan: number sstp: string
citySpan: number
} }
const PRODUCTS: ProductOption[] = [
{ id: 29, name: "极狐IP", l2tp: "8899", sstp: "4430" },
{ id: 3, name: "极光IP", l2tp: "1234", sstp: "4430" },
// { id: 18, name: "蘑菇IP", l2tp: "8899", sstp: "4430" },
{ id: 27, name: "麒麟IP", l2tp: "123", sstp: "4432" },
{ id: 26, name: "猎豹IP", l2tp: "1234", sstp: "4430" },
{ id: 28, name: "水滴独享IP", l2tp: "123", sstp: "4432" },
{ id: 22, name: "火狐静态IP", l2tp: "888888", sstp: "5908" },
]
function toText(value: string | number): string { function toText(value: string | number): string {
return value === null || value === undefined ? "" : String(value) return value === null || value === undefined ? "" : String(value)
} }
@@ -28,7 +45,6 @@ function onlineClass(value: string | number): string {
} }
export default function IpLinePage() { export default function IpLinePage() {
const [products, setProducts] = useState<ProductOption[]>([])
const [productId, setProductId] = useState<number | null>(null) const [productId, setProductId] = useState<number | null>(null)
const [rows, setRows] = useState<LineItem[]>([]) const [rows, setRows] = useState<LineItem[]>([])
const [count, setCount] = useState<number | string>(0) const [count, setCount] = useState<number | string>(0)
@@ -37,34 +53,16 @@ export default function IpLinePage() {
const [searching, setSearching] = useState(false) const [searching, setSearching] = useState(false)
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 [pageIndex, setPageIndex] = useState(0)
useEffect(() => { const [pageSize, setPageSize] = useState(100)
fetchProducts().then(result => { const requestSeq = useRef(0)
if (!result.success) {
setError(result.message)
setLoading(false)
return
}
const list = result.data
.filter(p => p.Product.OnLine === 1)
.sort((a, b) => a.Product.Sort - b.Product.Sort)
.map(p => ({ id: p.Product.Id, name: p.Product.Name }))
setProducts(list)
if (list[0]) {
setProductId(list[0].id)
} else {
setLoading(false)
setError(null)
}
})
}, [])
const loadDisplay = useCallback(async (pid: number) => { const loadDisplay = useCallback(async (pid: number) => {
const seq = ++requestSeq.current
setLoading(true) setLoading(true)
setError(null) setError(null)
const result = await fetchLineData(pid) const result = await fetchLineData(pid)
console.log(result, "resultresultresultresult") if (seq !== requestSeq.current) return
if (result.success) { if (result.success) {
setRows(result.data.data ?? []) setRows(result.data.data ?? [])
setCount(result.data.count ?? 0) setCount(result.data.count ?? 0)
@@ -78,36 +76,53 @@ export default function IpLinePage() {
setLoading(false) setLoading(false)
}, []) }, [])
// 首次进入展示全部产品线路product=0
useEffect(() => { useEffect(() => {
if (productId !== null) { loadDisplay(0)
setSearching(false) }, [loadDisplay])
setKeyword("")
loadDisplay(productId)
}
}, [productId, loadDisplay])
// 每5分钟自动刷新搜索状态除外
useEffect(() => { useEffect(() => {
if (productId === null) return
const timer = setInterval(() => { const timer = setInterval(() => {
if (!searching) loadDisplay(productId) if (!searching) loadDisplay(productId ?? 0)
}, REFRESH_INTERVAL) }, REFRESH_INTERVAL)
return () => clearInterval(timer) return () => clearInterval(timer)
}, [productId, searching, loadDisplay]) }, [productId, searching, loadDisplay])
// 数据变化后修正越界页码
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
if (pageIndex >= totalPages) setPageIndex(totalPages - 1)
}, [rows.length, pageIndex, pageSize])
const handleSelectProduct = (id: number) => {
const next = productId === id ? null : id
setProductId(next)
setSearching(false)
setKeyword("")
setPageIndex(0)
loadDisplay(next ?? 0)
}
const handleSearch = async () => { const handleSearch = async () => {
if (productId === null) return
const info = keyword.trim() const info = keyword.trim()
if (!info) { if (!info) {
if (searching) { if (searching) {
setSearching(false) setSearching(false)
await loadDisplay(productId) setPageIndex(0)
await loadDisplay(productId ?? 0)
} }
return return
} }
setSearching(true) setSearching(true)
setLoading(true) setLoading(true)
setError(null) setError(null)
const result = await searchLineData(productId, info) setPageIndex(0)
const seq = ++requestSeq.current
const result = await searchLineData(productId ?? 0, info)
console.log("搜索的result", result)
if (seq !== requestSeq.current) return
if (result.success) { if (result.success) {
setRows(result.data.data ?? []) setRows(result.data.data ?? [])
} else { } else {
@@ -117,16 +132,14 @@ export default function IpLinePage() {
setLoading(false) setLoading(false)
} }
const handleCopy = async (text: string) => { const handleCopy = useCallback(async (text: string) => {
try { try {
await navigator.clipboard.writeText(text) await navigator.clipboard.writeText(text)
toast.success("复制成功") toast.success("复制成功")
} catch { } catch {
toast.error("复制失败,请手动复制") toast.error("复制失败,请手动复制")
} }
} }, [])
const currentName = products.find(p => p.id === productId)?.name ?? ""
const handleExport = () => { const handleExport = () => {
if (rows.length === 0) { if (rows.length === 0) {
@@ -167,39 +180,20 @@ export default function IpLinePage() {
toast.success("导出成功") toast.success("导出成功")
} }
const displayRows = useMemo<DisplayRow[]>(() => { const currentProduct = PRODUCTS.find(p => p.id === productId)
const out: DisplayRow[] = [] const currentName = currentProduct?.name ?? "全部产品"
let i = 0
while (i < rows.length) { const pageRows = useMemo(() => {
const item = rows[i] const start = pageIndex * pageSize
let cityEnd = i + 1 return rows.slice(start, start + pageSize)
while ( }, [rows, pageIndex, pageSize])
cityEnd < rows.length &&
rows[cityEnd].name === item.name && const status = loading ? "load" : error ? "fail" : "done"
rows[cityEnd].city === item.city
) {
cityEnd += 1
}
let nameEnd = cityEnd
while (nameEnd < rows.length && rows[nameEnd].name === item.name) {
nameEnd += 1
}
for (let k = i; k < cityEnd; k += 1) {
out.push({
item: rows[k],
nameSpan: k === i ? nameEnd - i : 0,
citySpan: k === i ? cityEnd - i : 0,
})
}
i = cityEnd
}
return out
}, [rows])
return ( return (
<> <>
<Toaster position="top-center" richColors /> <Toaster position="top-center" richColors />
<Wrap className="flex flex-col gap-4 mt-30 max-w-6xl mx-auto px-4 pb-20"> <Wrap className="flex flex-col gap-4 mt-30 max-w-6xl mx-auto px-4 pb-20 bg-white">
<div className="grid grid-cols-3"> <div className="grid grid-cols-3">
<div></div> <div></div>
<h3 className="text-3xl font-bold text-gray-800 text-center"> <h3 className="text-3xl font-bold text-gray-800 text-center">
@@ -237,19 +231,20 @@ export default function IpLinePage() {
</ul> </ul>
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
{products.map(p => ( {PRODUCTS.map(p => (
<button <Button
key={p.id} key={p.id}
onClick={() => setProductId(p.id)} variant={p.id === productId ? "default" : "outline"}
onClick={() => handleSelectProduct(p.id)}
className={cn( className={cn(
"px-6 py-1.5 rounded-full text-sm font-medium transition-colors cursor-pointer", "px-6 py-1.5 rounded-full",
p.id === productId p.id === productId
? "text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200" ? "text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200 hover:opacity-90"
: "text-gray-600 border border-gray-200 hover:border-blue-400 bg-white", : "border-gray-200 bg-white text-gray-600 hover:border-blue-400 hover:text-blue-500",
)} )}
> >
{p.name} {p.name}
</button> </Button>
))} ))}
</div> </div>
@@ -281,15 +276,16 @@ export default function IpLinePage() {
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm" className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
/> />
</div> </div>
<button <Button
onClick={handleSearch} onClick={handleSearch}
className="px-8 py-2 bg-linear-to-r from-cyan-400 to-blue-500 text-white rounded-full text-sm font-medium shadow-md shadow-blue-200 cursor-pointer hover:opacity-90" className="px-8 rounded-full bg-linear-to-r from-cyan-400 to-blue-500 text-white shadow-md shadow-blue-200 hover:opacity-90"
> >
线 线
</button> </Button>
<button <Button
variant="outline"
onClick={handleExport} onClick={handleExport}
className="flex items-center gap-1.5 px-4 py-2 border border-blue-400 text-blue-500 rounded text-sm bg-white hover:bg-blue-50 transition-colors cursor-pointer" className="gap-1.5 px-4 rounded border-blue-400 text-blue-500 bg-white hover:bg-blue-50 hover:text-blue-500"
> >
<svg <svg
className="w-4 h-4" className="w-4 h-4"
@@ -305,17 +301,21 @@ export default function IpLinePage() {
/> />
</svg> </svg>
</button> </Button>
</div> </div>
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium"> <div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
<div className="flex items-center gap-2 text-orange-500"> <div className="flex items-center gap-2 text-orange-500">
<span>L2TP密钥</span> <span>L2TP密钥</span>
<span className="text-orange-600">1234</span> <span className="text-orange-600">
{currentProduct?.l2tp || ""}
</span>
</div> </div>
<div className="flex items-center gap-2 text-orange-500"> <div className="flex items-center gap-2 text-orange-500">
<span>STTP端口</span> <span>STTP端口</span>
<span className="text-orange-600">4430</span> <span className="text-orange-600">
{currentProduct?.sstp || ""}
</span>
</div> </div>
</div> </div>
@@ -348,108 +348,106 @@ export default function IpLinePage() {
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" /> <span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
<span className="text-gray-400"> <span className="text-gray-400 break-all">
{currentName}- (5,访) :{" "} {currentName}- (5,访) :
{PHP_API_BASE_URL}/script/linedata/display.php?product=
{productId ?? 0}
</span> </span>
</div> </div>
</div> </div>
<div className="border border-gray-200 rounded overflow-hidden"> {error && (
<table className="w-full text-center text-sm"> <div className="flex items-center justify-center gap-3 bg-red-50 border border-red-100 text-red-500 rounded-lg py-2 px-4 text-sm">
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium"> <span>{error}</span>
<tr> <Button
<th className="py-3 px-4 border-r border-gray-200 w-24"> variant="link"
onClick={() => loadDisplay(productId ?? 0)}
</th> className="text-blue-500 hover:underline"
<th className="py-3 px-4 border-r border-gray-200 w-32"> >
</th> </Button>
<th className="py-3 px-4 border-r border-gray-200 w-48"> </div>
)}
</th>
<th className="py-3 px-4 border-r border-gray-200"> <DataTable<LineItem>
data={pageRows}
</th> status={status}
<th className="py-3 px-4 border-r border-gray-200 w-24"> columns={useMemo<ColumnDef<LineItem>[]>(
() => [
</th> {
<th className="py-3 px-4 w-24"></th> accessorKey: "name",
</tr> header: "产品",
</thead> cell: ({ row }) => toText(row.original.name),
<tbody className="bg-white divide-y divide-gray-100"> },
{loading ? ( {
<tr> accessorKey: "city",
<td colSpan={6} className="py-10 text-gray-400"> header: "城市",
... cell: ({ row }) => toText(row.original.city),
</td> },
</tr> {
) : error && rows.length === 0 ? ( accessorKey: "supply",
<tr> header: "运营商",
<td colSpan={6} className="py-10 text-gray-400"> cell: ({ row }) => toText(row.original.supply),
{error} },
<button {
onClick={() => accessorKey: "nasname",
productId !== null && loadDisplay(productId) header: "服务器域名",
} cell: ({ row }) => {
className="ml-3 text-blue-500 hover:underline cursor-pointer" const text = toText(row.original.nasname)
> if (!text) return null
return (
</button> <span>
</td> {text}
</tr> <Button
) : rows.length === 0 ? ( variant="ghost"
<tr> onClick={() => handleCopy(text)}
<td colSpan={6} className="py-10 text-gray-400"> className="ml-2 h-auto px-1 py-0 text-green-500 hover:bg-transparent hover:text-green-700 cursor-pointer"
{searching ? "未找到匹配线路" : "暂无线路数据"}
</td>
</tr>
) : (
displayRows.map((row, index) => (
<tr key={`${row.item.name}-${row.item.nasname}-${index}`}>
{row.nameSpan > 0 && (
<td
rowSpan={row.nameSpan}
className="py-3 px-4 border-r border-gray-200 text-gray-500 align-top"
> >
{toText(row.item.name) || "-"}
</td> </Button>
)} </span>
{row.citySpan > 0 && ( )
<td },
rowSpan={row.citySpan} },
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium align-top" {
> accessorKey: "daikuan",
{toText(row.item.city) || "-"} header: "带宽",
</td> cell: ({ row }) => toText(row.original.daikuan),
)} },
<td className="py-3 px-4 border-r border-gray-200 text-gray-500"> {
{toText(row.item.supply) || "-"} accessorKey: "online",
</td> header: "服务器状态",
<td className="py-3 px-4 border-r border-gray-200"> cell: ({ row }) => {
<span>{toText(row.item.nasname) || "-"}</span> const text = toText(row.original.online)
{toText(row.item.nasname) && ( return (
<button <span className={onlineClass(row.original.online)}>
onClick={() => handleCopy(toText(row.item.nasname))} {text}
className="ml-2 text-green-500 cursor-pointer hover:text-green-700" </span>
> )
},
</button> },
)} ],
</td> [handleCopy],
<td className="py-3 px-4 border-r border-gray-200"> )}
{toText(row.item.daikuan) || "-"} stickyHeader
</td> maxHeight="max-h-[640px]"
<td classNames={{ headRow: "bg-gray-50 text-gray-600" }}
className={cn("py-3 px-4", onlineClass(row.item.online))} pagination={
> rows.length > 0
{toText(row.item.online) || "-"} ? {
</td> page: pageIndex + 1,
</tr> size: pageSize,
)) total: rows.length,
)} sizeOptions: PAGE_SIZE_OPTIONS,
</tbody> onPageChange: page => setPageIndex(page - 1),
</table> onSizeChange: size => {
</div> setPageSize(size)
setPageIndex(0)
},
}
: undefined
}
/>
</Wrap> </Wrap>
</> </>
) )

View File

@@ -1,15 +1,38 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { EyeIcon, EyeOffIcon } from "lucide-react" import { EyeIcon, EyeOffIcon } from "lucide-react"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { Link, useLocation, useNavigate } from "react-router-dom" import { Link, useLocation, useNavigate } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { z } from "zod"
import { apiLogin, apiRegister } from "@/api/auth" import { apiLogin, apiRegister } from "@/api/auth"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox" import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import bg from "./_assets/bg.webp" import bg from "./_assets/bg.webp"
const loginSchema = z.object({
account: z.string().trim().min(1, "请输入账号"),
password: z.string().min(1, "请输入密码"),
remember: z.boolean(),
})
type LoginValues = z.infer<typeof loginSchema>
const registerSchema = z.object({
phone: z.string().regex(/^1\d{10}$/, "请输入正确的手机号"),
smsCode: z.string().min(3, "请输入短信验证码").max(6, "请输入短信验证码"),
password: z.string().trim().min(1, "请输入密码"),
wx: z.string(),
qq: z.string(),
agreed: z.boolean().refine(v => v, "请先阅读并同意用户协议和隐私政策"),
})
type RegisterValues = z.infer<typeof registerSchema>
export default function LoginPage() { export default function LoginPage() {
const [tab, setTab] = useState<"login" | "register">("login") const [tab, setTab] = useState<"login" | "register">("login")
const location = useLocation() const location = useLocation()
@@ -69,29 +92,25 @@ export default function LoginPage() {
function LoginForm() { function LoginForm() {
const navigate = useNavigate() const navigate = useNavigate()
const [account, setAccount] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [remember, setRemember] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const handleSubmit = async (e: React.FormEvent) => { const form = useForm<LoginValues>({
e.preventDefault() resolver: zodResolver(loginSchema),
if (!account.trim()) { defaultValues: {
toast.error("请输入账号") account: "",
return password: "",
} remember: false,
if (!password) { },
toast.error("请输入密码") })
return
} const onSubmit = async (data: LoginValues) => {
setSubmitting(true) setSubmitting(true)
try { try {
const resp = await apiLogin({ const resp = await apiLogin({
logincode: account, logincode: data.account,
password, password: data.password,
}) })
console.log(resp, "resp")
if (resp.success) { if (resp.success) {
toast.success("登录成功", { toast.success("登录成功", {
@@ -109,53 +128,75 @@ function LoginForm() {
} }
return ( return (
<form className="space-y-4" onSubmit={handleSubmit}> <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-1"> <div className="space-y-1">
<label htmlFor="account" className="text-sm font-medium text-gray-700"> <label htmlFor="account" className="text-sm font-medium text-gray-700">
</label> </label>
<Input <Controller
id="account" name="account"
type="text" control={form.control}
placeholder="请输入会员号" render={({ field, fieldState }) => (
className="w-full" <>
value={account} <Input
onChange={e => setAccount(e.target.value)} id="account"
type="text"
placeholder="请输入会员号"
className="w-full"
{...field}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label htmlFor="password" className="text-sm font-medium text-gray-700"> <label htmlFor="password" className="text-sm font-medium text-gray-700">
</label> </label>
<div className="relative"> <Controller
<Input name="password"
id="password" control={form.control}
type={showPassword ? "text" : "password"} render={({ field, fieldState }) => (
placeholder="请输入密码" <>
className="w-full pr-10" <div className="relative">
value={password} <Input
onChange={e => setPassword(e.target.value)} id="password"
/> type={showPassword ? "text" : "password"}
<button placeholder="请输入密码"
type="button" className="w-full pr-10"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" {...field}
onClick={() => setShowPassword(!showPassword)} />
tabIndex={-1} <button
> type="button"
{showPassword ? ( className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
<EyeOffIcon className="size-4" /> onClick={() => setShowPassword(!showPassword)}
) : ( tabIndex={-1}
<EyeIcon className="size-4" /> >
)} {showPassword ? (
</button> <EyeOffIcon className="size-4" />
</div> ) : (
<EyeIcon className="size-4" />
)}
</button>
</div>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div> </div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Controller
id="remember" name="remember"
checked={remember} control={form.control}
onCheckedChange={v => setRemember(!!v)} render={({ field }) => (
<Checkbox
id="remember"
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/> />
<label htmlFor="remember" className="text-gray-600 cursor-pointer"> <label htmlFor="remember" className="text-gray-600 cursor-pointer">
@@ -184,27 +225,31 @@ function LoginForm() {
} }
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) { function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
const [phone, setPhone] = useState("")
const [smsCode, setSmsCode] = useState("")
const [smsCountdown, setSmsCountdown] = useState(0) const [smsCountdown, setSmsCountdown] = useState(0)
const [smsLoading, setSmsLoading] = useState(false) const [smsLoading, setSmsLoading] = useState(false)
const [newPassword, setNewPassword] = useState("")
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [wx, setWx] = useState("")
const [qq, setQq] = useState("")
const [agreed, setAgreed] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const form = useForm<RegisterValues>({
resolver: zodResolver(registerSchema),
defaultValues: {
phone: "",
smsCode: "",
password: "",
wx: "",
qq: "",
agreed: false,
},
})
const handleSendSMS = async () => { const handleSendSMS = async () => {
if (!/^1\d{10}$/.test(phone)) { const valid = await form.trigger("phone")
toast.error("请输入正确的手机号") if (!valid) return
return
}
setSmsLoading(true) setSmsLoading(true)
try { try {
const { sendSMS } = await import("@/api/verify") const { sendSMS } = await import("@/api/verify")
const resp = await sendSMS(phone, "User_Code") const resp = await sendSMS(form.getValues("phone"), "User_Code")
if (!resp.success) { if (!resp.success) {
throw new Error(resp.message || "短信发送失败") throw new Error(resp.message || "短信发送失败")
} }
@@ -231,34 +276,15 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
} }
} }
const handleSubmit = async (e: React.FormEvent) => { const onSubmit = async (data: RegisterValues) => {
e.preventDefault()
if (!/^1\d{10}$/.test(phone)) {
toast.error("请输入正确的手机号")
return
}
if (smsCode.length < 3 || smsCode.length > 6) {
toast.error("请输入短信验证码")
return
}
if (!newPassword.trim()) {
toast.error("请输入密码")
return
}
if (!agreed) {
toast.error("请先阅读并同意用户协议和隐私政策")
return
}
setSubmitting(true) setSubmitting(true)
try { try {
const resp = await apiRegister({ const resp = await apiRegister({
phone, phone: data.phone,
password: newPassword, password: data.password,
code: smsCode, code: data.smsCode,
wx, wx: data.wx,
qq, qq: data.qq,
}) })
if (resp.success) { if (resp.success) {
@@ -275,7 +301,7 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
} }
return ( return (
<form className="space-y-4" onSubmit={handleSubmit}> <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-1"> <div className="space-y-1">
<label <label
htmlFor="reg-account" htmlFor="reg-account"
@@ -283,29 +309,48 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
> >
</label> </label>
<Input <Controller
id="reg-account" name="phone"
type="text" control={form.control}
placeholder="请输入手机号码" render={({ field, fieldState }) => (
className="w-full" <>
value={phone} <Input
onChange={e => setPhone(e.target.value)} id="reg-account"
type="text"
placeholder="请输入手机号码"
className="w-full"
{...field}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label htmlFor="reg-sms" className="text-sm font-medium text-gray-700"> <label htmlFor="reg-sms" className="text-sm font-medium text-gray-700">
</label> </label>
<div className="flex gap-2"> <div className="flex items-start gap-2">
<Input <Controller
id="reg-sms" name="smsCode"
type="text" control={form.control}
placeholder="请输入短信验证码" render={({ field, fieldState }) => (
className="h-10" <div className="flex-1 space-y-1">
maxLength={6} <Input
autoComplete="one-time-code" id="reg-sms"
value={smsCode} type="text"
onChange={e => setSmsCode(e.target.value.replace(/\D/g, ""))} placeholder="请输入短信验证码"
className="h-10"
maxLength={6}
autoComplete="one-time-code"
{...field}
onChange={e =>
field.onChange(e.target.value.replace(/\D/g, ""))
}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</div>
)}
/> />
<Button <Button
type="button" type="button"
@@ -329,41 +374,54 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
> >
</label> </label>
<div className="relative"> <Controller
<Input name="password"
id="reg-password" control={form.control}
type={showPassword ? "text" : "password"} render={({ field, fieldState }) => (
placeholder="请输入密码" <>
className="w-full pr-10" <div className="relative">
value={newPassword} <Input
onChange={e => setNewPassword(e.target.value)} id="reg-password"
/> type={showPassword ? "text" : "password"}
<button placeholder="请输入密码"
type="button" className="w-full pr-10"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" {...field}
onClick={() => setShowPassword(!showPassword)} />
tabIndex={-1} <button
> type="button"
{showPassword ? ( className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
<EyeIcon className="size-4" /> onClick={() => setShowPassword(!showPassword)}
) : ( tabIndex={-1}
<EyeOffIcon className="size-4" /> >
)} {showPassword ? (
</button> <EyeIcon className="size-4" />
</div> ) : (
<EyeOffIcon className="size-4" />
)}
</button>
</div>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label htmlFor="reg-wx" className="text-sm font-medium text-gray-700"> <label htmlFor="reg-wx" className="text-sm font-medium text-gray-700">
<span className="text-gray-400 font-normal text-xs ml-1">()</span> <span className="text-gray-400 font-normal text-xs ml-1">()</span>
</label> </label>
<Input <Controller
id="reg-wx" name="wx"
type="text" control={form.control}
placeholder="请输入微信号" render={({ field }) => (
className="w-full" <Input
value={wx} id="reg-wx"
onChange={e => setWx(e.target.value)} type="text"
placeholder="请输入微信号"
className="w-full"
{...field}
/>
)}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -371,35 +429,52 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
QQ号 QQ号
<span className="text-gray-400 font-normal text-xs ml-1">()</span> <span className="text-gray-400 font-normal text-xs ml-1">()</span>
</label> </label>
<Input <Controller
id="reg-qq" name="qq"
type="text" control={form.control}
placeholder="请输入QQ号" render={({ field }) => (
className="w-full" <Input
value={qq} id="reg-qq"
onChange={e => setQq(e.target.value.replace(/\D/g, ""))} type="text"
placeholder="请输入QQ号"
className="w-full"
{...field}
onChange={e => field.onChange(e.target.value.replace(/\D/g, ""))}
/>
)}
/> />
</div> </div>
<div className="flex items-center space-x-2"> <div className="space-y-1">
<Checkbox <Controller
id="agreement" name="agreed"
checked={agreed} control={form.control}
onCheckedChange={v => setAgreed(!!v)} render={({ field, fieldState }) => (
<>
<div className="flex items-center space-x-2">
<Checkbox
id="agreement"
checked={field.value}
onCheckedChange={field.onChange}
/>
<label
htmlFor="agreement"
className="text-xs text-gray-600 cursor-pointer"
>
<a
href="/xieyi.html"
className="text-blue-600 hover:text-blue-500"
target="_blank"
rel="noopener"
>
IP JUIP.COM用户注册协议
</a>
</label>
</div>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/> />
<label
htmlFor="agreement"
className="text-xs text-gray-600 cursor-pointer"
>
<a
href="/xieyi.html"
className="text-blue-600 hover:text-blue-500"
target="_blank"
rel="noopener"
>
IP JUIP.COM用户注册协议
</a>
</label>
</div> </div>
<Button <Button
type="submit" type="submit"

View File

@@ -1,8 +1,13 @@
import { useEffect, useRef, useState } from "react" import { useEffect, useRef, useState } from "react"
import { useSearchParams } from "react-router-dom" import { useSearchParams } from "react-router-dom"
import { fetchProducts } from "@/api/product" import {
fetchProducts,
fetchStaticProducts,
fetchWindowProducts,
} from "@/api/product"
import Wrap from "@/components/wrap" import Wrap from "@/components/wrap"
import type { Tab } from "@/lib/models/product" import type { ApiResponse } from "@/lib/api"
import type { ProductItem, Tab, TabSource } from "@/lib/models/product"
import { transformToTabs } from "@/lib/models/product" import { transformToTabs } from "@/lib/models/product"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import ProductGrid from "./productGrid" import ProductGrid from "./productGrid"
@@ -13,11 +18,20 @@ import PurchaseInfo from "./purchaseInfo"
const TAB_ALIAS: Record<string, string> = { const TAB_ALIAS: Record<string, string> = {
short: "dynamic", short: "dynamic",
long: "dynamic", long: "dynamic",
http: "http",
tunnel: "dynamic", tunnel: "dynamic",
exclusive: "static", exclusive: "static",
} }
const TAB_SOURCES: {
id: string
label: string
fetch: () => Promise<ApiResponse<ProductItem[]>>
}[] = [
{ id: "dynamic", label: "动态独享IP", fetch: fetchProducts },
{ id: "static", label: "静态IP", fetch: fetchStaticProducts },
{ id: "window", label: "单窗口单IP", fetch: fetchWindowProducts },
]
export default function ProductPage() { export default function ProductPage() {
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const [tabs, setTabs] = useState<Tab[]>([]) const [tabs, setTabs] = useState<Tab[]>([])
@@ -29,19 +43,56 @@ export default function ProductPage() {
const initializedRef = useRef(false) const initializedRef = useRef(false)
useEffect(() => { useEffect(() => {
fetchProducts().then(result => { let cancelled = false
if (result.success) {
const newTabs = transformToTabs(result.data) const load = async () => {
setTabs(newTabs) const results = await Promise.allSettled(
if (!initializedRef.current && newTabs[0]?.brands[0]) { TAB_SOURCES.map(source => source.fetch()),
setActiveBrandId(newTabs[0].brands[0].id) )
initializedRef.current = true console.log(results, "resultsresultsresults")
if (cancelled) return
const sources: TabSource[] = []
const failedLabels: string[] = []
TAB_SOURCES.forEach((source, index) => {
const result = results[index]
if (result?.status === "fulfilled" && result.value.success) {
sources.push({
id: source.id,
label: source.label,
data: result.value.data,
})
} else {
failedLabels.push(source.label)
} }
} else { })
setError(result.message)
if (sources.length === 0) {
setError("产品列表加载失败,请稍后重试")
setLoading(false)
return
}
if (failedLabels.length > 0) {
console.warn(`部分产品列表加载失败: ${failedLabels.join("、")}`)
}
const newTabs = transformToTabs(sources)
setTabs(newTabs)
if (!initializedRef.current && newTabs[0]?.brands[0]) {
setActiveBrandId(newTabs[0].brands[0].id)
initializedRef.current = true
} }
setLoading(false) setLoading(false)
}) }
load()
return () => {
cancelled = true
}
}, []) }, [])
const urlTab = searchParams.get("tab") || "dynamic" const urlTab = searchParams.get("tab") || "dynamic"

View File

@@ -15,13 +15,13 @@ export default function ProductTabs({
}: ProductTabsProps) { }: ProductTabsProps) {
return ( return (
<Tabs value={activeId} onValueChange={onTabChange} className="w-full"> <Tabs value={activeId} onValueChange={onTabChange} className="w-full">
<TabsList className="grid w-full grid-cols-3 bg-slate-100 rounded-xl h-14"> <TabsList className="grid w-full h-auto bg-transparent p-0 grid-cols-3">
{tabs.map(tab => ( {tabs.map(tab => (
<TabsTrigger <TabsTrigger
key={tab.id} key={tab.id}
value={tab.id} value={tab.id}
className={cn( className={cn(
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center outline-none border-0 ring-0", "w-full h-auto py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center outline-none border-0 ring-0",
activeId === tab.id activeId === tab.id
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white! shadow-none" ? "bg-linear-to-r from-blue-500 to-cyan-400 text-white! shadow-none"
: "bg-gray-100 text-gray-600 hover:bg-gray-200", : "bg-gray-100 text-gray-600 hover:bg-gray-200",

View File

@@ -1,4 +1,7 @@
import { useState } from "react" import { zodResolver } from "@hookform/resolvers/zod"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card" import { Card, CardContent } from "@/components/ui/card"
@@ -9,6 +12,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
@@ -18,6 +22,14 @@ import alipay from "../_assets/alipay.svg"
import balance from "../_assets/balance.svg" import balance from "../_assets/balance.svg"
import wechat from "../_assets/wechat.svg" import wechat from "../_assets/wechat.svg"
const shippingSchema = z.object({
name: z.string().trim().min(1, "请输入收货人"),
phone: z.string().regex(/^1\d{10}$/, "请输入正确的手机号"),
address: z.string().trim().min(1, "请输入地址"),
})
type ShippingValues = z.infer<typeof shippingSchema>
const PRODUCT_VARIANTS = [ const PRODUCT_VARIANTS = [
{ {
id: 1, id: 1,

View File

@@ -23,7 +23,7 @@ export default function SoftDownloadPage() {
) )
return ( return (
<Wrap className="flex flex-col gap-6 mt-30"> <Wrap className="flex flex-col gap-6 mt-30 bg-white">
<div className="grid grid-cols-3"> <div className="grid grid-cols-3">
<div></div> <div></div>
<h3 className="text-3xl font-bold text-gray-800 text-center"> <h3 className="text-3xl font-bold text-gray-800 text-center">

67
src/lib/models/order.ts Normal file
View File

@@ -0,0 +1,67 @@
export type OrderItem = {
Id: number
OrderNo: string
OrderType: number
ProductId: number
ProductName: string
PackageName: string
DayPrice: number
ConnectCount: number
AccountCount: number
Accounts: string
OrderAmount: number
CouponAmount: number
PaymentAmount: number
CreateTime: string
UpdateTime: string
}
export type OrderFilters = {
orderType: string
productId: string
packageName: string
keyword: string
bTime: string
eTime: string
}
export const DEFAULT_FILTERS: OrderFilters = {
orderType: "0",
productId: "0",
packageName: "0",
keyword: "",
bTime: "",
eTime: "",
}
export const ORDER_TYPE_LABELS: Record<number, string> = {
1: "新开",
2: "续费",
3: "批量新开",
4: "批量续费",
}
export const PACKAGE_OPTIONS = [
"测试卡",
"天卡",
"周卡",
"月卡",
"月卡(活动)",
"双月卡(活动)",
"季卡",
"季卡(活动)",
"年卡",
]
export function formatOrderType(type: number): string {
return ORDER_TYPE_LABELS[type] ?? String(type)
}
export function cutAccount(accounts: string, max = 15): string {
if (accounts.length > max) return `${accounts.slice(0, max)}...`
return accounts
}
export function totalConnections(item: OrderItem): number {
return item.ConnectCount * item.AccountCount
}

View File

@@ -31,6 +31,12 @@ export type Tab = {
brands: Brand[] brands: Brand[]
} }
export type TabSource = {
id: string
label: string
data: ProductItem[]
}
export type ProductItem = { export type ProductItem = {
Product: { Product: {
Id: number Id: number
@@ -72,16 +78,12 @@ function extractTier(name: string): string {
return match ? match[1] : "默认" return match ? match[1] : "默认"
} }
export function transformToTabs(data: ProductItem[]): Tab[] { function transformToBrands(data: ProductItem[]): Brand[] {
const activeProducts = data.filter(p => p.Product.OnLine === 1) const activeProducts = data.filter(p => p.Product.OnLine === 1)
activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort) activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort)
console.log(
activeProducts,
"activeProductsactiveProductsactiveProductsactiveProducts",
)
const brands: Brand[] = activeProducts.map(item => { return activeProducts.map(item => {
const activePackages = item.Packages.filter(pkg => pkg.Status === 1) const activePackages = item.Packages.filter(pkg => pkg.Status === 1)
const tierMap = new Map<string, PackageItem[]>() const tierMap = new Map<string, PackageItem[]>()
@@ -122,18 +124,24 @@ export function transformToTabs(data: ProductItem[]): Tab[] {
products, products,
} }
}) })
}
brands.push({
id: "info", export function transformToTabs(sources: TabSource[]): Tab[] {
name: "购买须知", const tabs: Tab[] = []
products: [], for (const source of sources) {
}) const brands = transformToBrands(source.data)
if (brands.length === 0) continue
return [ tabs.push({ id: source.id, label: source.label, brands })
{ }
id: "dynamic",
label: "动态独享IP", const firstTab = tabs[0]
brands, if (firstTab) {
}, firstTab.brands.push({
] id: "info",
name: "购买须知",
products: [],
})
}
return tabs
} }

30
src/lib/models/record.ts Normal file
View File

@@ -0,0 +1,30 @@
export type HttpUsedSummary = {
todayShortPackIpUsed: number
shortPackIpUsed: number
}
export type HttpUsedLogItem = {
packType: number
userIp: string
ip: string
port: number
createTime: string
}
export type HttpUsedLogQuery = {
page: number
limit: number
packType: number
}
export const HTTP_USE_PACK_TYPE_LABELS: Record<number, string> = {
12: "储值套餐",
21: "短效包天套餐",
22: "短效包量套餐",
}
export const HTTP_USE_PACK_TYPE_OPTIONS = [21, 22]
export function formatPackType(packType: number): string {
return HTTP_USE_PACK_TYPE_LABELS[packType] ?? String(packType)
}

View File

@@ -1,10 +1,16 @@
import { createBrowserRouter } from "react-router-dom" import { createBrowserRouter } from "react-router-dom"
import Channels from "@/admin/channels/page" import Channels from "@/admin/channels/page"
import CouponPage from "@/admin/coupon/page"
import Dashboard from "@/admin/dashboard/page" import Dashboard from "@/admin/dashboard/page"
import Funds from "@/admin/funds/page" import Funds from "@/admin/funds/page"
import HttpRechargePage from "@/admin/httpRecharge/page"
import AdminLayout from "@/admin/layout" import AdminLayout from "@/admin/layout"
import LongPage from "@/admin/product/long" import OrdersPage from "@/admin/orders/page"
import ShortPage from "@/admin/product/short" import RecordPage from "@/admin/record/page"
import RefundOrdersPage from "@/admin/refundOrders/page"
import LongPage from "@/admin/resources/long"
import ShortPage from "@/admin/resources/short"
import RosorderPage from "@/admin/rosorder/page"
import Whitelist from "@/admin/whitelist/page" import Whitelist from "@/admin/whitelist/page"
import AuthGuard from "@/components/authGuard" import AuthGuard from "@/components/authGuard"
import HomePage from "@/home" import HomePage from "@/home"
@@ -57,6 +63,14 @@ export const router = createBrowserRouter([
{ path: "short", element: <ShortPage /> }, { path: "short", element: <ShortPage /> },
], ],
}, },
{ path: "orders", element: <OrdersPage /> },
{ path: "refundOrders", element: <RefundOrdersPage /> },
{ path: "httpRecharge", element: <HttpRechargePage /> },
{ path: "record", element: <RecordPage /> },
{ path: "coupon", element: <CouponPage /> },
{ path: "resources/long", element: <LongPage /> },
{ path: "resources/short", element: <ShortPage /> },
{ path: "rosorder", element: <RosorderPage /> },
], ],
}, },
]) ])

View File

@@ -23,7 +23,7 @@ export default defineConfig({
} }
}, },
}, },
"/script": "http://192.168.3.6:5000", "/script": "https://php-api.juip.com",
}, },
}, },
}) })