479 lines
13 KiB
TypeScript
479 lines
13 KiB
TypeScript
import type { ColumnDef } from "@tanstack/react-table"
|
||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||
import { useNavigate } from "react-router-dom"
|
||
import { toast } from "sonner"
|
||
import {
|
||
fetchCxOrders,
|
||
fetchCxPackages,
|
||
fetchLangIpList,
|
||
fetchLangIps,
|
||
fetchSwitchList,
|
||
} from "@/api/http"
|
||
import DataTable from "@/components/data-table"
|
||
import Page from "@/components/page"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||
import type {
|
||
CxOrder,
|
||
CxPackage,
|
||
LangIpItem,
|
||
SwitchRecord,
|
||
} from "@/lib/models/package"
|
||
import {
|
||
formatAbroad,
|
||
formatExpired,
|
||
formatIpState,
|
||
formatLineType,
|
||
formatOrderTypeLabel,
|
||
formatShareType,
|
||
formatSwitchReason,
|
||
LONG_ORDER_TYPE_LABELS,
|
||
parseExpired,
|
||
} from "@/lib/models/package"
|
||
import { downloadTextFile, estimateTotal, formatUnixTime } from "@/lib/utils"
|
||
import LongRenewDialog, { type LongRenewSource } from "./long-renew-dialog"
|
||
import LongSwitchDialog from "./long-switch-dialog"
|
||
|
||
const PAGE_SIZE = 50
|
||
|
||
export default function LongPage() {
|
||
const navigate = useNavigate()
|
||
const [activeTab, setActiveTab] = useState("ips")
|
||
|
||
// 已开通长效
|
||
const [ipList, setIpList] = useState<LangIpItem[]>([])
|
||
const [ipStatus, setIpStatus] = useState<"load" | "done" | "fail">("load")
|
||
const [ipPage, setIpPage] = useState(1)
|
||
const [ipTotal, setIpTotal] = useState(0)
|
||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||
|
||
// 全部套餐
|
||
const [packages, setPackages] = useState<CxPackage[]>([])
|
||
const [packageStatus, setPackageStatus] = useState<"load" | "done" | "fail">(
|
||
"load",
|
||
)
|
||
|
||
// 购买记录
|
||
const [orders, setOrders] = useState<CxOrder[]>([])
|
||
const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">(
|
||
"load",
|
||
)
|
||
|
||
// IP切换记录
|
||
const [switchList, setSwitchList] = useState<SwitchRecord[]>([])
|
||
const [switchStatus, setSwitchStatus] = useState<"load" | "done" | "fail">(
|
||
"load",
|
||
)
|
||
const [switchPage, setSwitchPage] = useState(1)
|
||
const [switchTotal, setSwitchTotal] = useState(0)
|
||
|
||
// 弹窗
|
||
const [renewSource, setRenewSource] = useState<LongRenewSource | null>(null)
|
||
const [switchTarget, setSwitchTarget] = useState<{
|
||
ip: LangIpItem
|
||
pop: 0 | 1
|
||
} | null>(null)
|
||
|
||
const loadIps = useCallback(async (page: number) => {
|
||
setIpStatus("load")
|
||
const res = await fetchLangIpList({
|
||
page,
|
||
limit: PAGE_SIZE,
|
||
packId: "",
|
||
isAbroad: "",
|
||
isExpired: 0,
|
||
state: -1,
|
||
ip: "",
|
||
groupId: "",
|
||
gameId: "",
|
||
cityCode: "",
|
||
orderBy: 0,
|
||
})
|
||
if (!res.success) {
|
||
setIpStatus("fail")
|
||
toast.error(res.message)
|
||
return
|
||
}
|
||
setIpList(res.data.items)
|
||
setIpTotal(
|
||
estimateTotal(
|
||
res.data.pager.totalCount,
|
||
res.data.pager.currentPage || page,
|
||
PAGE_SIZE,
|
||
res.data.items.length,
|
||
),
|
||
)
|
||
setIpStatus("done")
|
||
}, [])
|
||
|
||
const loadPackages = useCallback(async () => {
|
||
setPackageStatus("load")
|
||
const res = await fetchCxPackages()
|
||
if (!res.success) {
|
||
setPackageStatus("fail")
|
||
return
|
||
}
|
||
setPackages(res.data)
|
||
setPackageStatus("done")
|
||
}, [])
|
||
|
||
const loadOrders = useCallback(async () => {
|
||
setOrderStatus("load")
|
||
const res = await fetchCxOrders()
|
||
if (!res.success) {
|
||
setOrderStatus("fail")
|
||
return
|
||
}
|
||
setOrders(res.data)
|
||
setOrderStatus("done")
|
||
}, [])
|
||
|
||
const loadSwitch = useCallback(async (page: number) => {
|
||
setSwitchStatus("load")
|
||
const res = await fetchSwitchList({
|
||
page,
|
||
limit: PAGE_SIZE,
|
||
})
|
||
if (!res.success) {
|
||
setSwitchStatus("fail")
|
||
toast.error(res.message)
|
||
return
|
||
}
|
||
setSwitchList(res.data.items)
|
||
setSwitchTotal(
|
||
estimateTotal(
|
||
res.data.pager.totalCount,
|
||
res.data.pager.currentPage || page,
|
||
PAGE_SIZE,
|
||
res.data.items.length,
|
||
),
|
||
)
|
||
setSwitchStatus("done")
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
loadIps(1)
|
||
}, [loadIps])
|
||
|
||
const handleTabChange = (value: string) => {
|
||
setActiveTab(value)
|
||
if (value === "packages" && !packages.length) loadPackages()
|
||
if (value === "orders" && !orders.length) loadOrders()
|
||
if (value === "switch" && !switchList.length) loadSwitch(1)
|
||
}
|
||
|
||
const handlePageChange = (page: number) => {
|
||
setIpPage(page)
|
||
setRowSelection({})
|
||
loadIps(page)
|
||
}
|
||
|
||
const handleSwitchPageChange = (page: number) => {
|
||
setSwitchPage(page)
|
||
loadSwitch(page)
|
||
}
|
||
|
||
const handleExtract = async () => {
|
||
const ids = Object.keys(rowSelection)
|
||
if (ids.length === 0) {
|
||
toast.error("请选择IP!")
|
||
return
|
||
}
|
||
const res = await fetchLangIps(ids.join(","))
|
||
if (!res.success) {
|
||
toast.error(res.message)
|
||
return
|
||
}
|
||
if (res.data.length === 0) {
|
||
toast.error("提取失败,请稍后重试")
|
||
return
|
||
}
|
||
const content = res.data
|
||
.map(
|
||
ip =>
|
||
`${ip.ip}|${ip.httpPort}|${ip.socks5Port}|${ip.username}|${ip.password}`,
|
||
)
|
||
.join("\n")
|
||
downloadTextFile("ip.txt", content)
|
||
toast.success("提取成功,已开始下载")
|
||
}
|
||
|
||
const ipColumns = useMemo<ColumnDef<LangIpItem>[]>(
|
||
() => [
|
||
{
|
||
accessorKey: "isRelayed",
|
||
header: "模式",
|
||
cell: ({ row }) => (row.original.isRelayed ? "中继" : "直连"),
|
||
},
|
||
{ accessorKey: "ip", header: "节点入口IP" },
|
||
{ accessorKey: "realIp", header: "隧道出口IP" },
|
||
{
|
||
id: "ports",
|
||
header: "HTTP/SK5端口",
|
||
cell: ({ row }) =>
|
||
`${row.original.httpPort}/${row.original.socks5Port}`,
|
||
},
|
||
{ accessorKey: "area", header: "归属地" },
|
||
{
|
||
id: "bandwidth",
|
||
header: "带宽",
|
||
cell: ({ row }) =>
|
||
`${row.original.bandwidth}M/${formatLineType(row.original.lineType)}`,
|
||
},
|
||
{
|
||
accessorKey: "shareType",
|
||
header: "线路",
|
||
cell: ({ row }) => formatShareType(row.original.shareType),
|
||
},
|
||
{
|
||
accessorKey: "state",
|
||
header: "状态",
|
||
cell: ({ row }) => formatIpState(row.original.state),
|
||
},
|
||
{
|
||
id: "account",
|
||
header: "账号/密码",
|
||
cell: ({ row }) => `${row.original.username}/${row.original.password}`,
|
||
},
|
||
{
|
||
accessorKey: "expireTime",
|
||
header: "过期时间",
|
||
cell: ({ row }) => formatUnixTime(row.original.expireTime),
|
||
},
|
||
{
|
||
accessorKey: "isExpired",
|
||
header: "是否过期",
|
||
cell: ({ row }) =>
|
||
parseExpired(row.original.isExpired) ? "已过期" : "未过期",
|
||
},
|
||
{
|
||
id: "actions",
|
||
header: "操作",
|
||
cell: ({ row }) => {
|
||
const ip = row.original
|
||
return (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setRenewSource({ kind: "ip", ip })}
|
||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||
>
|
||
续费
|
||
</Button>
|
||
{ip.isAbroad === 1 && ip.isRelayed === 1 && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setSwitchTarget({ ip, pop: 1 })}
|
||
>
|
||
切换入口
|
||
</Button>
|
||
)}
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => setSwitchTarget({ ip, pop: 0 })}
|
||
>
|
||
切换节点
|
||
</Button>
|
||
</div>
|
||
)
|
||
},
|
||
},
|
||
],
|
||
[],
|
||
)
|
||
|
||
const packageColumns = useMemo<ColumnDef<CxPackage>[]>(
|
||
() => [
|
||
{
|
||
accessorKey: "name",
|
||
header: "套餐名称",
|
||
cell: ({ row }) => row.original.name || "-",
|
||
},
|
||
{
|
||
accessorKey: "isAbroad",
|
||
header: "套餐类型",
|
||
cell: ({ row }) => formatAbroad(row.original.isAbroad),
|
||
},
|
||
{
|
||
accessorKey: "shareType",
|
||
header: "模式",
|
||
cell: ({ row }) => formatShareType(row.original.shareType),
|
||
},
|
||
{ accessorKey: "ipAmount", header: "可用IP数量(个)" },
|
||
{
|
||
accessorKey: "isExpired",
|
||
header: "是否过期",
|
||
cell: ({ row }) => formatExpired(row.original.isExpired),
|
||
},
|
||
{
|
||
accessorKey: "expireTime",
|
||
header: "过期时间",
|
||
cell: ({ row }) => formatUnixTime(row.original.expireTime),
|
||
},
|
||
{
|
||
id: "actions",
|
||
header: "续费",
|
||
cell: ({ row }) => (
|
||
<Button
|
||
size="sm"
|
||
onClick={() =>
|
||
setRenewSource({ kind: "package", pkg: row.original })
|
||
}
|
||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||
>
|
||
续费
|
||
</Button>
|
||
),
|
||
},
|
||
],
|
||
[],
|
||
)
|
||
|
||
const orderColumns = useMemo<ColumnDef<CxOrder>[]>(
|
||
() => [
|
||
{ accessorKey: "order_no", header: "订单号" },
|
||
{
|
||
accessorKey: "order_type",
|
||
header: "订单类型",
|
||
cell: ({ row }) =>
|
||
formatOrderTypeLabel(LONG_ORDER_TYPE_LABELS, row.original.order_type),
|
||
},
|
||
{ accessorKey: "game_type", header: "游戏类型" },
|
||
{ accessorKey: "ip_count", header: "IP数量" },
|
||
{ accessorKey: "money", header: "实付金额" },
|
||
{ accessorKey: "create_time", header: "支付时间" },
|
||
{
|
||
id: "rebuy",
|
||
header: "再次购买",
|
||
cell: () => (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => navigate("/product/http")}
|
||
>
|
||
再次购买
|
||
</Button>
|
||
),
|
||
},
|
||
],
|
||
[navigate],
|
||
)
|
||
|
||
const switchColumns = useMemo<ColumnDef<SwitchRecord>[]>(
|
||
() => [
|
||
{ accessorKey: "oldIp", header: "原IP" },
|
||
{ accessorKey: "oldArea", header: "原属地" },
|
||
{ accessorKey: "newIp", header: "切换后IP" },
|
||
{ accessorKey: "newArea", header: "切换后属地" },
|
||
{
|
||
accessorKey: "reason",
|
||
header: "切换方式",
|
||
cell: ({ row }) => formatSwitchReason(row.original.reason),
|
||
},
|
||
{
|
||
accessorKey: "createTime",
|
||
header: "切换时间",
|
||
cell: ({ row }) => formatUnixTime(row.original.createTime),
|
||
},
|
||
],
|
||
[],
|
||
)
|
||
|
||
return (
|
||
<Page>
|
||
<div className="bg-white rounded-lg p-4">
|
||
<h3 className="font-semibold text-lg mb-4">长效套餐</h3>
|
||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||
<TabsList>
|
||
<TabsTrigger value="ips">已开通长效</TabsTrigger>
|
||
<TabsTrigger value="packages">全部套餐</TabsTrigger>
|
||
<TabsTrigger value="orders">购买记录</TabsTrigger>
|
||
<TabsTrigger value="switch">IP切换记录</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="ips" className="pt-4 flex flex-col gap-3">
|
||
<div className="flex items-center gap-4 flex-wrap">
|
||
<Button
|
||
onClick={() => handleExtract()}
|
||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||
>
|
||
批量提取IP
|
||
</Button>
|
||
<span className="text-xs text-gray-500">
|
||
格式:IP|http端口|socks5端口|账号|密码
|
||
</span>
|
||
</div>
|
||
<DataTable
|
||
data={ipList}
|
||
status={ipStatus}
|
||
columns={ipColumns}
|
||
enableSelection
|
||
rowSelection={rowSelection}
|
||
onSelectionChange={setRowSelection}
|
||
getRowId={row => String(row.id)}
|
||
pagination={{
|
||
page: ipPage,
|
||
size: PAGE_SIZE,
|
||
total: ipTotal,
|
||
sizeOptions: [PAGE_SIZE],
|
||
onPageChange: handlePageChange,
|
||
}}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="packages" className="pt-4">
|
||
<DataTable
|
||
data={packages}
|
||
status={packageStatus}
|
||
columns={packageColumns}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="orders" className="pt-4">
|
||
<DataTable
|
||
data={orders}
|
||
status={orderStatus}
|
||
columns={orderColumns}
|
||
/>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="switch" className="pt-4">
|
||
<DataTable
|
||
data={switchList}
|
||
status={switchStatus}
|
||
columns={switchColumns}
|
||
pagination={{
|
||
page: switchPage,
|
||
size: PAGE_SIZE,
|
||
total: switchTotal,
|
||
sizeOptions: [PAGE_SIZE],
|
||
onPageChange: handleSwitchPageChange,
|
||
}}
|
||
/>
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
|
||
<LongRenewDialog
|
||
open={renewSource !== null}
|
||
source={renewSource}
|
||
onClose={() => setRenewSource(null)}
|
||
onSuccess={() => {
|
||
loadIps(ipPage)
|
||
loadPackages()
|
||
}}
|
||
/>
|
||
|
||
<LongSwitchDialog
|
||
open={switchTarget !== null}
|
||
ip={switchTarget?.ip ?? null}
|
||
pop={switchTarget?.pop ?? 0}
|
||
onClose={() => setSwitchTarget(null)}
|
||
onSuccess={() => loadIps(ipPage)}
|
||
/>
|
||
</Page>
|
||
)
|
||
}
|