2026-09-08 16:19:40 +08:00
|
|
|
|
import type { ColumnDef } from "@tanstack/react-table"
|
|
|
|
|
|
import { SearchIcon } from "lucide-react"
|
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
2026-09-18 13:17:23 +08:00
|
|
|
|
import { Link } from "react-router-dom"
|
2026-08-03 10:53:12 +08:00
|
|
|
|
import { toast } from "sonner"
|
2026-09-08 16:19:40 +08:00
|
|
|
|
import { fetchMyAccounts } from "@/api/account"
|
2026-08-03 10:53:12 +08:00
|
|
|
|
import DataTable from "@/components/data-table"
|
2026-07-30 13:55:28 +08:00
|
|
|
|
import Page from "@/components/page"
|
2026-08-03 10:53:12 +08:00
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
|
import { Input } from "@/components/ui/input"
|
2026-09-08 16:19:40 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "@/components/ui/select"
|
|
|
|
|
|
import { useProducts } from "@/lib/hooks/useProducts"
|
|
|
|
|
|
import {
|
|
|
|
|
|
EXPIRED_DAY_OPTIONS,
|
|
|
|
|
|
type MyAccountItem,
|
|
|
|
|
|
type MyAccountQueryParams,
|
|
|
|
|
|
} from "@/lib/models/account"
|
|
|
|
|
|
|
|
|
|
|
|
type ChannelQuery = {
|
|
|
|
|
|
expiredDay: string
|
|
|
|
|
|
productId: string
|
|
|
|
|
|
packageName: string
|
|
|
|
|
|
keyword: string
|
|
|
|
|
|
}
|
2026-08-03 10:53:12 +08:00
|
|
|
|
|
2026-09-08 16:19:40 +08:00
|
|
|
|
const INITIAL_QUERY: ChannelQuery = {
|
|
|
|
|
|
expiredDay: "-100",
|
|
|
|
|
|
productId: "0",
|
|
|
|
|
|
packageName: "0",
|
|
|
|
|
|
keyword: "",
|
|
|
|
|
|
}
|
2026-08-03 10:53:12 +08:00
|
|
|
|
|
2026-09-08 16:19:40 +08:00
|
|
|
|
const PAGE_SIZE = 10
|
2026-08-03 10:53:12 +08:00
|
|
|
|
|
2026-09-08 16:19:40 +08:00
|
|
|
|
function toParams(query: ChannelQuery): MyAccountQueryParams {
|
|
|
|
|
|
return {
|
|
|
|
|
|
ExpiredDay: Number(query.expiredDay),
|
|
|
|
|
|
ProductId: Number(query.productId) || undefined,
|
|
|
|
|
|
PackageId: query.packageName === "0" ? undefined : query.packageName,
|
|
|
|
|
|
Keyword: query.keyword.trim() || undefined,
|
2026-08-03 10:53:12 +08:00
|
|
|
|
}
|
2026-09-08 16:19:40 +08:00
|
|
|
|
}
|
2026-08-03 10:53:12 +08:00
|
|
|
|
|
2026-09-08 16:19:40 +08:00
|
|
|
|
const columns: ColumnDef<MyAccountItem>[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "StartTime",
|
|
|
|
|
|
header: "开通时间",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "ProductName",
|
|
|
|
|
|
header: "产品",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "PackageName",
|
|
|
|
|
|
header: "套餐",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "Account",
|
|
|
|
|
|
header: "账号",
|
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<span className="font-mono text-sm">{row.original.Account}</span>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "Pwd",
|
|
|
|
|
|
header: "密码",
|
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<span className="font-mono text-sm">{row.original.Pwd}</span>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "ConnectCount",
|
|
|
|
|
|
header: "连接数",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "EndTime",
|
|
|
|
|
|
header: "到期时间",
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "RestTime",
|
|
|
|
|
|
header: "剩余时间",
|
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
|
<span
|
|
|
|
|
|
className={
|
|
|
|
|
|
row.original.RestTime === "已过期" ? "text-red-500" : undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
{row.original.RestTime}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
accessorKey: "Status",
|
|
|
|
|
|
header: "状态",
|
|
|
|
|
|
cell: ({ row }) =>
|
|
|
|
|
|
row.original.Status === 1 ? (
|
|
|
|
|
|
<span className="text-emerald-600">正常</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span className="text-red-500">已退货</span>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
2026-08-03 10:53:12 +08:00
|
|
|
|
|
2026-09-08 16:19:40 +08:00
|
|
|
|
export default function ChannelsPage() {
|
|
|
|
|
|
const products = useProducts()
|
|
|
|
|
|
const [query, setQuery] = useState<ChannelQuery>(INITIAL_QUERY)
|
|
|
|
|
|
|
|
|
|
|
|
const [data, setData] = useState<MyAccountItem[]>([])
|
|
|
|
|
|
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
|
|
|
|
|
const [page, setPage] = useState(1)
|
2026-09-18 13:17:23 +08:00
|
|
|
|
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
2026-09-08 16:19:40 +08:00
|
|
|
|
|
|
|
|
|
|
const selectedProduct = useMemo(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
products.find(item => String(item.Product.Id) === query.productId) ??
|
|
|
|
|
|
null,
|
|
|
|
|
|
[products, query.productId],
|
|
|
|
|
|
)
|
2026-08-03 10:53:12 +08:00
|
|
|
|
|
2026-09-08 16:19:40 +08:00
|
|
|
|
const load = useCallback(async (params: MyAccountQueryParams) => {
|
|
|
|
|
|
setStatus("load")
|
|
|
|
|
|
const res = await fetchMyAccounts(params)
|
|
|
|
|
|
if (res.success) {
|
|
|
|
|
|
setData(res.data)
|
|
|
|
|
|
setStatus("done")
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setStatus("fail")
|
|
|
|
|
|
toast.error(res.message)
|
2026-08-03 10:53:12 +08:00
|
|
|
|
}
|
2026-09-08 16:19:40 +08:00
|
|
|
|
setPage(1)
|
2026-09-18 13:17:23 +08:00
|
|
|
|
setSelection({})
|
2026-09-08 16:19:40 +08:00
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
|
|
const loadedRef = useRef(false)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (loadedRef.current) return
|
|
|
|
|
|
loadedRef.current = true
|
2026-09-11 17:06:29 +08:00
|
|
|
|
load(toParams(INITIAL_QUERY))
|
2026-09-08 16:19:40 +08:00
|
|
|
|
}, [load])
|
|
|
|
|
|
|
|
|
|
|
|
const applyFilters = (patch: Partial<ChannelQuery>) => {
|
|
|
|
|
|
const next = { ...query, ...patch }
|
|
|
|
|
|
setQuery(next)
|
2026-09-11 17:06:29 +08:00
|
|
|
|
load(toParams(next))
|
2026-08-03 10:53:12 +08:00
|
|
|
|
}
|
2026-09-18 13:17:23 +08:00
|
|
|
|
const handleReset = () => {
|
|
|
|
|
|
setQuery(INITIAL_QUERY)
|
|
|
|
|
|
load(toParams(INITIAL_QUERY))
|
|
|
|
|
|
}
|
2026-09-08 16:19:40 +08:00
|
|
|
|
const paged = useMemo(
|
|
|
|
|
|
() => data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
|
|
|
|
|
[data, page],
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-03 10:53:12 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<Page>
|
2026-09-18 13:17:23 +08:00
|
|
|
|
<div className="bg-white rounded-lg flex flex-col">
|
|
|
|
|
|
<div className="flex flex-col items-center bg-amber-100 p-3 gap-2">
|
|
|
|
|
|
<p className="text-sm text-gray-600">
|
|
|
|
|
|
为了给您带来更好的服务体验,请完善qq号和微信号。
|
|
|
|
|
|
<Link
|
|
|
|
|
|
to="/admin"
|
|
|
|
|
|
className="text-blue-600 hover:text-blue-700 underline-offset-2 hover:underline"
|
|
|
|
|
|
>
|
|
|
|
|
|
立即完善》
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex flex-wrap items-end p-4 gap-x-4 gap-y-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<span className="text-sm text-gray-500 whitespace-nowrap">
|
|
|
|
|
|
过期时间:
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={query.expiredDay}
|
|
|
|
|
|
onValueChange={value => applyFilters({ expiredDay: value })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="w-40">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{EXPIRED_DAY_OPTIONS.map(option => (
|
|
|
|
|
|
<SelectItem key={option.value} value={String(option.value)}>
|
|
|
|
|
|
{option.label}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-08 16:19:40 +08:00
|
|
|
|
<Select
|
2026-09-18 13:17:23 +08:00
|
|
|
|
value={query.productId}
|
|
|
|
|
|
onValueChange={value =>
|
|
|
|
|
|
applyFilters({ productId: value, packageName: "0" })
|
|
|
|
|
|
}
|
2026-09-08 16:19:40 +08:00
|
|
|
|
>
|
2026-09-18 13:17:23 +08:00
|
|
|
|
<SelectTrigger className="w-36">
|
|
|
|
|
|
<SelectValue placeholder="全部产品" />
|
2026-09-08 16:19:40 +08:00
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-09-18 13:17:23 +08:00
|
|
|
|
<SelectItem value="0">全部产品</SelectItem>
|
|
|
|
|
|
{products.map(item => (
|
|
|
|
|
|
<SelectItem key={item.Product.Id} value={String(item.Product.Id)}>
|
|
|
|
|
|
{item.Product.Name}
|
2026-09-08 16:19:40 +08:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
2026-09-18 13:17:23 +08:00
|
|
|
|
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={query.packageName}
|
|
|
|
|
|
onValueChange={value => applyFilters({ packageName: value })}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger className="w-36">
|
|
|
|
|
|
<SelectValue placeholder="全部套餐" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
<SelectItem value="0">全部套餐</SelectItem>
|
|
|
|
|
|
{selectedProduct?.Packages.filter(pkg => pkg.Status === 1).map(
|
|
|
|
|
|
pkg => (
|
|
|
|
|
|
<SelectItem key={pkg.Id} value={pkg.Name}>
|
|
|
|
|
|
{pkg.Name}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
),
|
|
|
|
|
|
)}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<Input
|
|
|
|
|
|
value={query.keyword}
|
|
|
|
|
|
onChange={e => setQuery({ ...query, keyword: e.target.value })}
|
|
|
|
|
|
onKeyDown={e => {
|
|
|
|
|
|
if (e.key === "Enter") applyFilters({})
|
|
|
|
|
|
}}
|
|
|
|
|
|
placeholder="账号"
|
|
|
|
|
|
className="w-40"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button onClick={() => applyFilters({})}>
|
|
|
|
|
|
<SearchIcon data-icon="inline-start" />
|
|
|
|
|
|
查询
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="outline" onClick={handleReset}>
|
|
|
|
|
|
重置
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2026-09-08 16:19:40 +08:00
|
|
|
|
</div>
|
2026-08-03 10:53:12 +08:00
|
|
|
|
|
2026-09-18 13:17:23 +08:00
|
|
|
|
<div className="flex flex-wrap items-center gap-4 pt-2 p-4">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
className="bg-red-600 hover:bg-red-700 text-white"
|
|
|
|
|
|
onClick={() => toast.info("续费功能开发中,敬请期待")}
|
|
|
|
|
|
>
|
|
|
|
|
|
续费
|
2026-08-03 10:53:12 +08:00
|
|
|
|
</Button>
|
2026-09-18 13:17:23 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
|
|
|
|
|
onClick={() => toast.info("退货功能开发中,敬请期待")}
|
|
|
|
|
|
>
|
|
|
|
|
|
退货
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
className="bg-amber-500 hover:bg-amber-600 text-white"
|
|
|
|
|
|
onClick={() => toast.info("批量修改密码功能开发中,敬请期待")}
|
|
|
|
|
|
>
|
|
|
|
|
|
批量修改密码
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<span className="text-sm text-red-500">
|
|
|
|
|
|
*批量续费可通过筛选和搜索功能精准定位账号
|
|
|
|
|
|
</span>
|
2026-08-03 10:53:12 +08:00
|
|
|
|
</div>
|
2026-09-08 16:19:40 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
|
|
|
|
|
<h3 className="font-semibold text-lg">IP账号管理</h3>
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
data={paged}
|
|
|
|
|
|
status={status}
|
|
|
|
|
|
columns={columns}
|
2026-09-18 13:17:23 +08:00
|
|
|
|
enableSelection
|
|
|
|
|
|
rowSelection={selection}
|
|
|
|
|
|
onSelectionChange={setSelection}
|
|
|
|
|
|
getRowId={row => String(row.Id)}
|
2026-08-03 10:53:12 +08:00
|
|
|
|
pagination={{
|
2026-09-11 17:06:29 +08:00
|
|
|
|
page,
|
2026-09-08 16:19:40 +08:00
|
|
|
|
size: PAGE_SIZE,
|
|
|
|
|
|
total: data.length,
|
|
|
|
|
|
onPageChange: setPage,
|
2026-08-03 10:53:12 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Page>
|
|
|
|
|
|
)
|
2026-07-30 13:55:28 +08:00
|
|
|
|
}
|