Files
juip-web/src/admin/resources/long.tsx
2026-09-18 13:17:23 +08:00

479 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}