Files
juip-web/src/admin/resources/long.tsx

479 lines
13 KiB
TypeScript
Raw Normal View History

2026-08-31 14:49:28 +08:00
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"
2026-08-31 14:49:28 +08:00
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,
2026-09-18 13:17:23 +08:00
formatExpired,
2026-08-31 14:49:28 +08:00
formatIpState,
2026-09-18 13:17:23 +08:00
formatLineType,
2026-08-31 14:49:28 +08:00
formatOrderTypeLabel,
2026-09-18 13:17:23 +08:00
formatShareType,
formatSwitchReason,
2026-08-31 14:49:28 +08:00
LONG_ORDER_TYPE_LABELS,
parseExpired,
} from "@/lib/models/package"
2026-09-18 13:17:23 +08:00
import { downloadTextFile, estimateTotal, formatUnixTime } from "@/lib/utils"
2026-08-31 14:49:28 +08:00
import LongRenewDialog, { type LongRenewSource } from "./long-renew-dialog"
import LongSwitchDialog from "./long-switch-dialog"
const PAGE_SIZE = 50
export default function LongPage() {
2026-08-31 14:49:28 +08:00
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(() => {
2026-09-11 17:06:29 +08:00
loadIps(1)
2026-08-31 14:49:28 +08:00
}, [loadIps])
const handleTabChange = (value: string) => {
setActiveTab(value)
2026-09-11 17:06:29 +08:00
if (value === "packages" && !packages.length) loadPackages()
if (value === "orders" && !orders.length) loadOrders()
if (value === "switch" && !switchList.length) loadSwitch(1)
2026-08-31 14:49:28 +08:00
}
const handlePageChange = (page: number) => {
setIpPage(page)
setRowSelection({})
2026-09-11 17:06:29 +08:00
loadIps(page)
2026-08-31 14:49:28 +08:00
}
const handleSwitchPageChange = (page: number) => {
setSwitchPage(page)
2026-09-11 17:06:29 +08:00
loadSwitch(page)
2026-08-31 14:49:28 +08:00
}
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 }) =>
2026-09-18 13:17:23 +08:00
`${row.original.bandwidth}M/${formatLineType(row.original.lineType)}`,
},
{
accessorKey: "shareType",
header: "线路",
cell: ({ row }) => formatShareType(row.original.shareType),
2026-08-31 14:49:28 +08:00
},
{
accessorKey: "state",
header: "状态",
cell: ({ row }) => formatIpState(row.original.state),
},
{
id: "account",
header: "账号/密码",
cell: ({ row }) => `${row.original.username}/${row.original.password}`,
},
2026-09-18 13:17:23 +08:00
{
accessorKey: "expireTime",
header: "过期时间",
cell: ({ row }) => formatUnixTime(row.original.expireTime),
},
2026-08-31 14:49:28 +08:00
{
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>[]>(
() => [
2026-09-18 13:17:23 +08:00
{
accessorKey: "name",
header: "套餐名称",
cell: ({ row }) => row.original.name || "-",
},
2026-08-31 14:49:28 +08:00
{
accessorKey: "isAbroad",
header: "套餐类型",
cell: ({ row }) => formatAbroad(row.original.isAbroad),
},
2026-09-18 13:17:23 +08:00
{
accessorKey: "shareType",
header: "模式",
cell: ({ row }) => formatShareType(row.original.shareType),
},
2026-08-31 14:49:28 +08:00
{ accessorKey: "ipAmount", header: "可用IP数量(个)" },
2026-09-18 13:17:23 +08:00
{
accessorKey: "isExpired",
header: "是否过期",
cell: ({ row }) => formatExpired(row.original.isExpired),
},
{
accessorKey: "expireTime",
header: "过期时间",
cell: ({ row }) => formatUnixTime(row.original.expireTime),
},
2026-08-31 14:49:28 +08:00
{
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: "切换后属地" },
2026-09-18 13:17:23 +08:00
{
accessorKey: "reason",
header: "切换方式",
cell: ({ row }) => formatSwitchReason(row.original.reason),
},
{
accessorKey: "createTime",
header: "切换时间",
cell: ({ row }) => formatUnixTime(row.original.createTime),
},
2026-08-31 14:49:28 +08:00
],
[],
)
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
2026-09-11 17:06:29 +08:00
onClick={() => handleExtract()}
2026-08-31 14:49:28 +08:00
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={{
2026-09-11 17:06:29 +08:00
page: ipPage,
2026-08-31 14:49:28 +08:00
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={{
2026-09-11 17:06:29 +08:00
page: switchPage,
2026-08-31 14:49:28 +08:00
size: PAGE_SIZE,
total: switchTotal,
sizeOptions: [PAGE_SIZE],
onPageChange: handleSwitchPageChange,
}}
/>
</TabsContent>
</Tabs>
</div>
<LongRenewDialog
open={renewSource !== null}
source={renewSource}
onClose={() => setRenewSource(null)}
onSuccess={() => {
2026-09-11 17:06:29 +08:00
loadIps(ipPage)
loadPackages()
2026-08-31 14:49:28 +08:00
}}
/>
<LongSwitchDialog
open={switchTarget !== null}
ip={switchTarget?.ip ?? null}
pop={switchTarget?.pop ?? 0}
onClose={() => setSwitchTarget(null)}
2026-09-11 17:06:29 +08:00
onSuccess={() => loadIps(ipPage)}
2026-08-31 14:49:28 +08:00
/>
</Page>
)
}