更新http管理二级路由页面

This commit is contained in:
Eamon
2026-09-18 13:17:23 +08:00
parent a902f5be29
commit eee72ad74a
29 changed files with 1109 additions and 539 deletions

View File

@@ -1,6 +1,7 @@
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { SearchIcon } from "lucide-react" import { SearchIcon } from "lucide-react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Link } from "react-router-dom"
import { toast } from "sonner" import { toast } from "sonner"
import { fetchMyAccounts } from "@/api/account" import { fetchMyAccounts } from "@/api/account"
import DataTable from "@/components/data-table" import DataTable from "@/components/data-table"
@@ -113,6 +114,7 @@ export default function ChannelsPage() {
const [data, setData] = useState<MyAccountItem[]>([]) const [data, setData] = useState<MyAccountItem[]>([])
const [status, setStatus] = useState<"load" | "done" | "fail">("load") const [status, setStatus] = useState<"load" | "done" | "fail">("load")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [selection, setSelection] = useState<Record<string, boolean>>({})
const selectedProduct = useMemo( const selectedProduct = useMemo(
() => () =>
@@ -132,6 +134,7 @@ export default function ChannelsPage() {
toast.error(res.message) toast.error(res.message)
} }
setPage(1) setPage(1)
setSelection({})
}, []) }, [])
const loadedRef = useRef(false) const loadedRef = useRef(false)
@@ -146,7 +149,10 @@ export default function ChannelsPage() {
setQuery(next) setQuery(next)
load(toParams(next)) load(toParams(next))
} }
const handleReset = () => {
setQuery(INITIAL_QUERY)
load(toParams(INITIAL_QUERY))
}
const paged = useMemo( const paged = useMemo(
() => data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
[data, page], [data, page],
@@ -154,80 +160,121 @@ export default function ChannelsPage() {
return ( return (
<Page> <Page>
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3"> <div className="bg-white rounded-lg flex flex-col">
<div className="flex items-center gap-2"> <div className="flex flex-col items-center bg-amber-100 p-3 gap-2">
<span className="text-sm text-gray-500 whitespace-nowrap"> <p className="text-sm text-gray-600">
过期时间: 为了给您带来更好的服务体验,请完善qq号和微信号。
</span> <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>
<Select <Select
value={query.expiredDay} value={query.productId}
onValueChange={value => applyFilters({ expiredDay: value })} onValueChange={value =>
applyFilters({ productId: value, packageName: "0" })
}
> >
<SelectTrigger className="w-40"> <SelectTrigger className="w-36">
<SelectValue /> <SelectValue placeholder="全部产品" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{EXPIRED_DAY_OPTIONS.map(option => ( <SelectItem value="0">全部产品</SelectItem>
<SelectItem key={option.value} value={String(option.value)}> {products.map(item => (
{option.label} <SelectItem key={item.Product.Id} value={String(item.Product.Id)}>
{item.Product.Name}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<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>
</div> </div>
<Select <div className="flex flex-wrap items-center gap-4 pt-2 p-4">
value={query.productId} <Button
onValueChange={value => className="bg-red-600 hover:bg-red-700 text-white"
applyFilters({ productId: value, packageName: "0" }) onClick={() => toast.info("续费功能开发中,敬请期待")}
} >
> 续费
<SelectTrigger className="w-36">
<SelectValue placeholder="全部产品" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">全部产品</SelectItem>
{products.map(item => (
<SelectItem key={item.Product.Id} value={String(item.Product.Id)}>
{item.Product.Name}
</SelectItem>
))}
</SelectContent>
</Select>
<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>
<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>
</div> </div>
</div> </div>
@@ -237,6 +284,10 @@ export default function ChannelsPage() {
data={paged} data={paged}
status={status} status={status}
columns={columns} columns={columns}
enableSelection
rowSelection={selection}
onSelectionChange={setSelection}
getRowId={row => String(row.Id)}
pagination={{ pagination={{
page, page,
size: PAGE_SIZE, size: PAGE_SIZE,

View File

@@ -15,7 +15,7 @@ export default function HttpRechargePage() {
const load = useCallback(async () => { const load = useCallback(async () => {
setStatus("load") setStatus("load")
const res = await fetchHttpRechargeList() const res = await fetchHttpRechargeList()
console.log(res, 'fewewf'); console.log(res, "fewewf")
if (!res.success) { if (!res.success) {
setStatus("fail") setStatus("fail")

View File

@@ -4,16 +4,29 @@ import AccountCell from "@/components/composites/accountCell"
import OrderFilterBar from "@/components/composites/orderFilterBar" import OrderFilterBar from "@/components/composites/orderFilterBar"
import DataTable from "@/components/data-table" import DataTable from "@/components/data-table"
import Page from "@/components/page" import Page from "@/components/page"
import { MY_ORDER_PAGE_SIZE, useMyOrderPage } from "@/lib/hooks/useMyOrderPage" import { useMyOrderPage } from "@/lib/hooks/useMyOrderPage"
import { useProducts } from "@/lib/hooks/useProducts" import { useProducts } from "@/lib/hooks/useProducts"
import { import {
formatDate, formatDate,
formatMoney, formatMoney,
formatOrderType, formatOrderType,
type MyOrderQueryParams,
type OrderFilters,
type OrderItem, type OrderItem,
totalConnections, totalConnections,
} from "@/lib/models/order" } from "@/lib/models/order"
const mapOrderFilters = (
filters: OrderFilters,
): Partial<MyOrderQueryParams> => ({
BTime: filters.bTime || undefined,
ETime: filters.eTime || undefined,
OrderType: filters.orderType === "0" ? undefined : Number(filters.orderType),
ProductId: filters.productId === "0" ? undefined : Number(filters.productId),
PackageId: filters.packageName === "0" ? undefined : filters.packageName,
KeyWord: filters.keyword.trim() || undefined,
})
const columns: ColumnDef<OrderItem>[] = [ const columns: ColumnDef<OrderItem>[] = [
{ {
accessorKey: "CreateTime", accessorKey: "CreateTime",
@@ -71,8 +84,19 @@ const columns: ColumnDef<OrderItem>[] = [
export default function OrdersPage() { export default function OrdersPage() {
const products = useProducts() const products = useProducts()
const { filters, patch, load, total, status, page, setPage, paged } = const {
useMyOrderPage(fetchMyOrders) filters,
patch,
load,
reset,
total,
status,
page,
size,
setPage,
changeSize,
paged,
} = useMyOrderPage(fetchMyOrders, mapOrderFilters)
return ( return (
<Page> <Page>
@@ -80,7 +104,8 @@ export default function OrdersPage() {
filters={filters} filters={filters}
products={products} products={products}
onPatch={patch} onPatch={patch}
onSearch={() => load()} onSearch={load}
onReset={reset}
/> />
<div className="bg-white rounded-lg p-4 flex flex-col gap-4"> <div className="bg-white rounded-lg p-4 flex flex-col gap-4">
@@ -91,9 +116,10 @@ export default function OrdersPage() {
columns={columns} columns={columns}
pagination={{ pagination={{
page, page,
size: MY_ORDER_PAGE_SIZE, size,
total, total,
onPageChange: setPage, onPageChange: setPage,
onSizeChange: changeSize,
}} }}
/> />
</div> </div>

View File

@@ -1,40 +0,0 @@
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)

View File

@@ -1,5 +1,7 @@
import type { ColumnDef } from "@tanstack/react-table" import type { ColumnDef } from "@tanstack/react-table"
import { useMemo, useState } from "react" import { useCallback, useEffect, useMemo, useState } from "react"
import { toast } from "sonner"
import { fetchHttpUsedLogs, fetchHttpUsedSummary } from "@/api/record"
import DataTable from "@/components/data-table" import DataTable from "@/components/data-table"
import Page from "@/components/page" import Page from "@/components/page"
import { import {
@@ -9,39 +11,11 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select" } from "@/components/ui/select"
import { import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
formatPackType, import { formatPackType, HTTP_USE_PACK_TYPE_OPTIONS } from "@/lib/models/record"
HTTP_USE_PACK_TYPE_OPTIONS, import { estimateTotal, formatUnixTime } from "@/lib/utils"
type HttpUsedLogItem,
} from "@/lib/models/record"
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
// TODO: 后端使用记录接口就绪后,将 mock 替换为 src/api/record.ts 的 const PAGE_SIZE_OPTIONS = [10, 20, 50]
// fetchHttpUsedSummary / fetchHttpUsedLogs(服务端分页)
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: { function StatCard(props: {
label: string label: string
@@ -61,38 +35,81 @@ function StatCard(props: {
} }
export default function RecordPage() { export default function RecordPage() {
const [summary, setSummary] = useState<HttpUsedSummary | null>(null)
const [packType, setPackType] = useState(HTTP_USE_PACK_TYPE_OPTIONS[0]) const [packType, setPackType] = useState(HTTP_USE_PACK_TYPE_OPTIONS[0])
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10) const [pageSize, setPageSize] = useState(10)
const [items, setItems] = useState<HttpUsedLogItem[]>([])
const [total, setTotal] = useState(0)
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
const filtered = useMemo( const loadLogs = useCallback(async () => {
() => MOCK_USED_LOGS.filter(log => log.packType === packType), setStatus("load")
[packType], const res = await fetchHttpUsedLogs({ page, limit: pageSize, packType })
) if (!res.success) {
setStatus("fail")
toast.error(res.message)
return
}
setItems(res.data.items)
setTotal(
estimateTotal(
res.data.pager.totalCount,
res.data.pager.currentPage || page,
pageSize,
res.data.items.length,
),
)
setStatus("done")
}, [page, pageSize, packType])
const paged = useMemo( useEffect(() => {
() => filtered.slice((page - 1) * pageSize, page * pageSize), fetchHttpUsedSummary().then(res => {
[filtered, page, pageSize], if (res.success) setSummary(res.data)
) })
}, [])
useEffect(() => {
loadLogs()
}, [loadLogs])
const handlePackTypeChange = (value: string) => { const handlePackTypeChange = (value: string) => {
setPackType(Number(value)) setPackType(Number(value))
setPage(1) setPage(1)
} }
const columns = useMemo<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: "使用时间",
cell: ({ row }) => formatUnixTime(row.original.createTime),
},
],
[],
)
return ( return (
<Page> <Page>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4"> <div className="bg-white rounded-lg p-4 flex flex-col gap-4">
<h3 className="font-semibold text-lg">包天包量使用概况</h3> <h3 className="font-semibold text-lg">包天包量使用概况</h3>
<div className="grid grid-cols-4 sm:grid-cols-4 gap-4"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<StatCard <StatCard
label="当日使用IP数量" label="当日使用IP数量"
value={MOCK_USED_SUMMARY.todayShortPackIpUsed} value={summary?.todayShortPackIpUsed ?? 0}
accent="amber" accent="amber"
/> />
<StatCard <StatCard
label="累计使用IP数量" label="累计使用IP数量"
value={MOCK_USED_SUMMARY.shortPackIpUsed} value={summary?.shortPackIpUsed ?? 0}
accent="blue" accent="blue"
/> />
</div> </div>
@@ -119,13 +136,14 @@ export default function RecordPage() {
</div> </div>
<DataTable <DataTable
data={paged} data={items}
status="done" status={status}
columns={columns} columns={columns}
pagination={{ pagination={{
page, page,
size: pageSize, size: pageSize,
total: filtered.length, total,
sizeOptions: PAGE_SIZE_OPTIONS,
onPageChange: setPage, onPageChange: setPage,
onSizeChange: size => { onSizeChange: size => {
setPageSize(size) setPageSize(size)

View File

@@ -4,15 +4,27 @@ import AccountCell from "@/components/composites/accountCell"
import OrderFilterBar from "@/components/composites/orderFilterBar" import OrderFilterBar from "@/components/composites/orderFilterBar"
import DataTable from "@/components/data-table" import DataTable from "@/components/data-table"
import Page from "@/components/page" import Page from "@/components/page"
import { MY_ORDER_PAGE_SIZE, useMyOrderPage } from "@/lib/hooks/useMyOrderPage" import { useMyOrderPage } from "@/lib/hooks/useMyOrderPage"
import { useProducts } from "@/lib/hooks/useProducts" import { useProducts } from "@/lib/hooks/useProducts"
import { import {
formatDate, formatDate,
formatMoney, formatMoney,
formatOrderType,
type MyOrderQueryParams,
type OrderFilters,
type OrderItem, type OrderItem,
totalConnections,
} from "@/lib/models/order" } from "@/lib/models/order"
const mapRefundFilters = (
filters: OrderFilters,
): Partial<MyOrderQueryParams> => ({
BTime: filters.bTime || undefined,
ETime: filters.eTime || undefined,
ProductId: filters.productId === "0" ? undefined : Number(filters.productId),
PackageId: filters.packageName === "0" ? undefined : filters.packageName,
KeyWord: filters.keyword.trim() || undefined,
})
const columns: ColumnDef<OrderItem>[] = [ const columns: ColumnDef<OrderItem>[] = [
{ {
accessorKey: "CreateTime", accessorKey: "CreateTime",
@@ -23,6 +35,11 @@ const columns: ColumnDef<OrderItem>[] = [
accessorKey: "OrderNo", accessorKey: "OrderNo",
header: "订单编号", header: "订单编号",
}, },
{
accessorKey: "OrderType",
header: "类型",
cell: ({ row }) => formatOrderType(row.original.OrderType),
},
{ {
accessorKey: "ProductName", accessorKey: "ProductName",
header: "产品", header: "产品",
@@ -39,7 +56,7 @@ const columns: ColumnDef<OrderItem>[] = [
{ {
accessorKey: "ConnectCount", accessorKey: "ConnectCount",
header: "连接数", header: "连接数",
cell: ({ row }) => totalConnections(row.original), cell: ({ row }) => row.original.ConnectCount,
}, },
{ {
accessorKey: "DayCount", accessorKey: "DayCount",
@@ -65,8 +82,19 @@ const columns: ColumnDef<OrderItem>[] = [
export default function RefundOrdersPage() { export default function RefundOrdersPage() {
const products = useProducts() const products = useProducts()
const { filters, patch, load, total, status, page, setPage, paged } = const {
useMyOrderPage(fetchMyRefundOrders) filters,
patch,
load,
reset,
total,
status,
page,
size,
setPage,
changeSize,
paged,
} = useMyOrderPage(fetchMyRefundOrders, mapRefundFilters)
return ( return (
<Page> <Page>
@@ -75,7 +103,8 @@ export default function RefundOrdersPage() {
products={products} products={products}
showOrderType={false} showOrderType={false}
onPatch={patch} onPatch={patch}
onSearch={() => load()} onSearch={load}
onReset={reset}
/> />
<div className="bg-white rounded-lg p-4 flex flex-col gap-4"> <div className="bg-white rounded-lg p-4 flex flex-col gap-4">
@@ -86,9 +115,10 @@ export default function RefundOrdersPage() {
columns={columns} columns={columns}
pagination={{ pagination={{
page, page,
size: MY_ORDER_PAGE_SIZE, size,
total, total,
onPageChange: setPage, onPageChange: setPage,
onSizeChange: changeSize,
}} }}
/> />
</div> </div>

View File

@@ -21,12 +21,16 @@ import type {
} from "@/lib/models/package" } from "@/lib/models/package"
import { import {
formatAbroad, formatAbroad,
formatExpired,
formatIpState, formatIpState,
formatLineType,
formatOrderTypeLabel, formatOrderTypeLabel,
formatShareType,
formatSwitchReason,
LONG_ORDER_TYPE_LABELS, LONG_ORDER_TYPE_LABELS,
parseExpired, parseExpired,
} from "@/lib/models/package" } from "@/lib/models/package"
import { downloadTextFile, estimateTotal } from "@/lib/utils" import { downloadTextFile, estimateTotal, formatUnixTime } from "@/lib/utils"
import LongRenewDialog, { type LongRenewSource } from "./long-renew-dialog" import LongRenewDialog, { type LongRenewSource } from "./long-renew-dialog"
import LongSwitchDialog from "./long-switch-dialog" import LongSwitchDialog from "./long-switch-dialog"
@@ -214,9 +218,13 @@ export default function LongPage() {
id: "bandwidth", id: "bandwidth",
header: "带宽", header: "带宽",
cell: ({ row }) => cell: ({ row }) =>
`${row.original.bandwidth}M/${row.original.lineType}`, `${row.original.bandwidth}M/${formatLineType(row.original.lineType)}`,
},
{
accessorKey: "shareType",
header: "线路",
cell: ({ row }) => formatShareType(row.original.shareType),
}, },
{ accessorKey: "shareType", header: "线路" },
{ {
accessorKey: "state", accessorKey: "state",
header: "状态", header: "状态",
@@ -227,7 +235,11 @@ export default function LongPage() {
header: "账号/密码", header: "账号/密码",
cell: ({ row }) => `${row.original.username}/${row.original.password}`, cell: ({ row }) => `${row.original.username}/${row.original.password}`,
}, },
{ accessorKey: "expireTime", header: "过期时间" }, {
accessorKey: "expireTime",
header: "过期时间",
cell: ({ row }) => formatUnixTime(row.original.expireTime),
},
{ {
accessorKey: "isExpired", accessorKey: "isExpired",
header: "是否过期", header: "是否过期",
@@ -274,14 +286,32 @@ export default function LongPage() {
const packageColumns = useMemo<ColumnDef<CxPackage>[]>( const packageColumns = useMemo<ColumnDef<CxPackage>[]>(
() => [ () => [
{
accessorKey: "name",
header: "套餐名称",
cell: ({ row }) => row.original.name || "-",
},
{ {
accessorKey: "isAbroad", accessorKey: "isAbroad",
header: "套餐类型", header: "套餐类型",
cell: ({ row }) => formatAbroad(row.original.isAbroad), cell: ({ row }) => formatAbroad(row.original.isAbroad),
}, },
{ accessorKey: "shareType", header: "模式" }, {
accessorKey: "shareType",
header: "模式",
cell: ({ row }) => formatShareType(row.original.shareType),
},
{ accessorKey: "ipAmount", header: "可用IP数量(个)" }, { accessorKey: "ipAmount", header: "可用IP数量(个)" },
{ accessorKey: "expireTime", header: "过期时间" }, {
accessorKey: "isExpired",
header: "是否过期",
cell: ({ row }) => formatExpired(row.original.isExpired),
},
{
accessorKey: "expireTime",
header: "过期时间",
cell: ({ row }) => formatUnixTime(row.original.expireTime),
},
{ {
id: "actions", id: "actions",
header: "续费", header: "续费",
@@ -337,8 +367,16 @@ export default function LongPage() {
{ accessorKey: "oldArea", header: "原属地" }, { accessorKey: "oldArea", header: "原属地" },
{ accessorKey: "newIp", header: "切换后IP" }, { accessorKey: "newIp", header: "切换后IP" },
{ accessorKey: "newArea", header: "切换后属地" }, { accessorKey: "newArea", header: "切换后属地" },
{ accessorKey: "reason", header: "切换方式" }, {
{ accessorKey: "createTime", header: "切换时间" }, accessorKey: "reason",
header: "切换方式",
cell: ({ row }) => formatSwitchReason(row.original.reason),
},
{
accessorKey: "createTime",
header: "切换时间",
cell: ({ row }) => formatUnixTime(row.original.createTime),
},
], ],
[], [],
) )

View File

@@ -33,7 +33,7 @@ export default function ShortPage() {
const loadPackages = useCallback(async () => { const loadPackages = useCallback(async () => {
setStatus("load") setStatus("load")
const res = await fetchDxPackages() const res = await fetchDxPackages()
console.log(res, 'resresresres'); console.log(res, "resresresres")
if (!res.success) { if (!res.success) {
setStatus("fail") setStatus("fail")
return return

View File

@@ -6,8 +6,6 @@ import Page from "@/components/page"
import { useList } from "@/lib/hooks/useList" import { useList } from "@/lib/hooks/useList"
import type { RosOrderItem } from "@/lib/models/rosorder" import type { RosOrderItem } from "@/lib/models/rosorder"
const PAGE_SIZE = 10
const columns: ColumnDef<RosOrderItem>[] = [ const columns: ColumnDef<RosOrderItem>[] = [
{ {
accessorKey: "username", accessorKey: "username",
@@ -52,9 +50,8 @@ const columns: ColumnDef<RosOrderItem>[] = [
export default function RosorderPage() { export default function RosorderPage() {
const { data, status } = useList(fetchRosOrderList) const { data, status } = useList(fetchRosOrderList)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE) const [pageSize, setPageSize] = useState(10)
// 接口返回全量列表,本地做前端分页
const paged = useMemo( const paged = useMemo(
() => data.slice((page - 1) * pageSize, page * pageSize), () => data.slice((page - 1) * pageSize, page * pageSize),
[data, page, pageSize], [data, page, pageSize],

View File

@@ -1,29 +1,89 @@
import { Lock, Trash2, Unlock } from "lucide-react" import { Lock, Trash2, Unlock } from "lucide-react"
import { useState } from "react" import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import {
createWhiteIp,
deleteWhiteIp,
fetchWhiteList,
lockWhiteIp,
type WhiteActionResult,
} from "@/api/whitelist"
import DataTable from "@/components/data-table" import DataTable from "@/components/data-table"
import Page from "@/components/page" import Page from "@/components/page"
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 { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import type { ApiResponse } from "@/lib/api"
import type { WhiteIpItem } from "@/lib/models/whitelist" import type { WhiteIpItem } from "@/lib/models/whitelist"
import { formatUnixTime } from "@/lib/utils"
// TODO: 后端白名单接口就绪后,将 saveWhiteIp/fetchWhiteList 接入(见 src/api/whitelist.ts)
export default function WhitelistPage() { export default function WhitelistPage() {
const [ip, setIp] = useState("") const [ip, setIp] = useState("")
const [list, setList] = useState<WhiteIpItem[]>([]) const [list, setList] = useState<WhiteIpItem[]>([])
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
const [saving, setSaving] = useState(false)
const handleSave = () => { const load = useCallback(async () => {
setStatus("load")
const res = await fetchWhiteList()
if (!res.success) {
setStatus("fail")
toast.error(res.message)
return
}
setList(res.data)
setStatus("done")
}, [])
useEffect(() => {
load()
}, [load])
const runAction = useCallback(
async (
action: () => Promise<ApiResponse<WhiteActionResult>>,
successMessage: string,
) => {
const res = await action()
if (!res.success) {
toast.error(res.message)
return
}
const { code, msg } = res.data
if (code !== undefined && code < 1) {
toast.error(msg || "操作失败")
return
}
toast.success(successMessage)
load()
},
[load],
)
const handleSave = async () => {
const value = ip.trim() const value = ip.trim()
if (!value) return if (!value) {
// TODO: await saveWhiteIp(value) toast.error("请输入IP地址")
setList(prev => [ return
{ id: Date.now(), ip: value, createTime: "", isLocked: "未锁定" }, }
...prev, setSaving(true)
]) await runAction(() => createWhiteIp(value), "添加成功")
setSaving(false)
setIp("") setIp("")
} }
const handleLock = (item: WhiteIpItem) => {
const locked = item.isLocked === "已锁定"
runAction(
() => lockWhiteIp(item.id, locked ? 0 : 1),
locked ? "解锁成功" : "锁定成功",
)
}
const handleDelete = (item: WhiteIpItem) => {
runAction(() => deleteWhiteIp(item.id), "删除成功")
}
return ( return (
<Page> <Page>
<div className="bg-white rounded-lg p-4"> <div className="bg-white rounded-lg p-4">
@@ -36,7 +96,9 @@ export default function WhitelistPage() {
placeholder="请输入IP地址并点击保存白名单" placeholder="请输入IP地址并点击保存白名单"
className="max-w-sm" className="max-w-sm"
/> />
<Button onClick={handleSave}>保存</Button> <Button onClick={handleSave} disabled={saving}>
保存
</Button>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -48,15 +110,18 @@ export default function WhitelistPage() {
<DataTable<WhiteIpItem> <DataTable<WhiteIpItem>
data={list} data={list}
status="done" status={status}
columns={[ columns={[
{ accessorKey: "ip", header: "IP地址" },
{ {
accessorKey: "ip", accessorKey: "description",
header: "IP地址", header: "备注",
cell: ({ row }) => row.original.description || "-",
}, },
{ {
accessorKey: "createTime", accessorKey: "createTime",
header: "设置时间", header: "设置时间",
cell: ({ row }) => formatUnixTime(row.original.createTime),
}, },
{ {
accessorKey: "isLocked", accessorKey: "isLocked",
@@ -75,41 +140,25 @@ export default function WhitelistPage() {
header: "操作", header: "操作",
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original const item = row.original
// TODO: 接入 lockWhiteIp / deleteWhiteIp const isLocked = item.isLocked === "已锁定"
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => onClick={() => handleLock(item)}
setList(prev =>
prev.map(i =>
i.id === item.id
? {
...i,
isLocked:
i.isLocked === "未锁定"
? "已锁定"
: "未锁定",
}
: i,
),
)
}
> >
{item.isLocked === "未锁定" ? ( {isLocked ? (
<Lock className="mr-1 h-3 w-3" />
) : (
<Unlock className="mr-1 h-3 w-3" /> <Unlock className="mr-1 h-3 w-3" />
) : (
<Lock className="mr-1 h-3 w-3" />
)} )}
{item.isLocked === "未锁定" ? "锁定" : "解锁"} {isLocked ? "解锁" : "锁定"}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => onClick={() => handleDelete(item)}
setList(prev => prev.filter(i => i.id !== item.id))
}
> >
<Trash2 className="mr-1 h-3 w-3" /> <Trash2 className="mr-1 h-3 w-3" />
删除 删除

View File

@@ -1,5 +1,16 @@
import { Lock, Minus, Plus, Trash2, Unlock } from "lucide-react" import { Lock, Minus, Plus, Trash2, Unlock } from "lucide-react"
import { useState } from "react" import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import {
createWhiteIpSu,
deleteWhiteIpSu,
fetchUnlimitedAvailable,
fetchWhiteLimitSu,
fetchWhiteListSu,
lockWhiteIpSu,
setWhiteLimitSu,
type WhiteActionResult,
} from "@/api/whitelist"
import DataTable from "@/components/data-table" import DataTable from "@/components/data-table"
import Page from "@/components/page" import Page from "@/components/page"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@@ -12,75 +23,144 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import type { WhiteIpItem } from "@/lib/models/whitelist" import type { ApiResponse } from "@/lib/api"
import type { UnlimitedAvailable, WhiteIpItem } from "@/lib/models/whitelist"
// TODO: 后端白名单接口就绪后,将 fetchWhiteList / setWhiteLimitMax 接入(见 src/api/whitelist.ts) import { formatUnixTime } from "@/lib/utils"
const clampLimit = (value: number) => Math.min(20, Math.max(1, value)) const clampLimit = (value: number) => Math.min(20, Math.max(1, value))
const discountOf = (limit: number) => Math.max(6, 11 - limit) function calcRemain(
expire: number,
maxWhitelist: number,
newMaxWhitelist: number,
): number {
const now = Math.floor(Date.now() / 1000)
const prev = expire || now
const remain = prev - now
const multiple = maxWhitelist / newMaxWhitelist
const oldDiscount = Math.max(0.6, 1 - 0.1 * (maxWhitelist - 1))
const newDiscount = Math.max(0.6, 1 - 0.1 * (newMaxWhitelist - 1))
const offset = oldDiscount / newDiscount
const fee = 99 / 100
return Math.floor(remain * multiple * offset * fee + now) * 1000
}
export default function WhitelistSUPage() { export default function WhitelistSUPage() {
const [ip, setIp] = useState("") const [ip, setIp] = useState("")
const [list, setList] = useState<WhiteIpItem[]>([]) const [list, setList] = useState<WhiteIpItem[]>([])
const [whiteLimit, setWhiteLimit] = useState(5) const [status, setStatus] = useState<"load" | "done" | "fail">("load")
const [tempWhiteLimit, setTempWhiteLimit] = useState(5) const [whiteLimit, setWhiteLimit] = useState(0)
const [tempDiscount, setTempDiscount] = useState(10) const [available, setAvailable] = useState<UnlimitedAvailable | null>(null)
const [tempWhiteLimit, setTempWhiteLimit] = useState(1)
const [isDialogOpen, setIsDialogOpen] = useState(false) const [isDialogOpen, setIsDialogOpen] = useState(false)
const [saving, setSaving] = useState(false)
// 调整后到期时间(演示用固定 +30 天) const load = useCallback(async () => {
const newExpire = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) setStatus("load")
const res = await fetchWhiteListSu()
if (!res.success) {
setStatus("fail")
toast.error(res.message)
return
}
setList(res.data)
setStatus("done")
}, [])
const setTempWhiteLimitHandler = (value: number) => { const loadLimit = useCallback(async () => {
const clamped = clampLimit(value) const res = await fetchWhiteLimitSu()
setTempWhiteLimit(clamped) if (res.success) setWhiteLimit(res.data)
setTempDiscount(discountOf(clamped)) }, [])
}
const increaseLimit = () => setTempWhiteLimitHandler(tempWhiteLimit + 1) const loadAvailable = useCallback(async () => {
const res = await fetchUnlimitedAvailable()
if (res.success) setAvailable(res.data)
}, [])
const decreaseLimit = () => setTempWhiteLimitHandler(tempWhiteLimit - 1) useEffect(() => {
load()
loadLimit()
loadAvailable()
}, [load, loadLimit, loadAvailable])
const openDialog = () => { const runAction = useCallback(
setTempWhiteLimit(whiteLimit) async (
setTempDiscount(discountOf(whiteLimit)) action: () => Promise<ApiResponse<WhiteActionResult>>,
setIsDialogOpen(true) successMessage: string,
} ) => {
const res = await action()
if (!res.success) {
toast.error(res.message)
return
}
const { code, msg } = res.data
if (code !== undefined && code < 1) {
toast.error(msg || "操作失败")
return
}
toast.success(successMessage)
load()
},
[load],
)
const confirmSetLimit = () => { const handleSaveIp = async () => {
setWhiteLimit(tempWhiteLimit)
setIsDialogOpen(false)
// TODO: await setWhiteLimitMax(tempWhiteLimit)
}
const handleSaveIp = () => {
const value = ip.trim() const value = ip.trim()
if (!value) return if (!value) {
// TODO: await saveWhiteIp(value) toast.error("请输入IP地址")
setList(prev => [ return
{ id: Date.now(), ip: value, createTime: "", isLocked: "未锁定" }, }
...prev, setSaving(true)
]) await runAction(() => createWhiteIpSu(value), "添加成功")
setSaving(false)
setIp("") setIp("")
} }
const toggleLock = (item: WhiteIpItem) => { const handleLock = (item: WhiteIpItem) => {
// TODO: await lockWhiteIp(item.id, item.isLocked !== "已锁定") const locked = item.isLocked === "已锁定"
setList(prev => runAction(
prev.map(i => () => lockWhiteIpSu(item.id, locked ? 0 : 1),
i.id === item.id locked ? "解锁成功" : "锁定成功",
? {
...i,
isLocked: i.isLocked === "未锁定" ? "已锁定" : "未锁定",
}
: i,
),
) )
} }
const removeIp = (item: WhiteIpItem) => { const handleDelete = (item: WhiteIpItem) => {
// TODO: await deleteWhiteIp(item.id) runAction(() => deleteWhiteIpSu(item.id), "删除成功")
setList(prev => prev.filter(i => i.id !== item.id)) }
const openDialog = () => {
setTempWhiteLimit(clampLimit(whiteLimit || 1))
setIsDialogOpen(true)
loadAvailable()
}
const increaseLimit = () => setTempWhiteLimit(prev => clampLimit(prev + 1))
const decreaseLimit = () => setTempWhiteLimit(prev => clampLimit(prev - 1))
const tempDiscount = Math.max(6, 11 - tempWhiteLimit)
const newExpre = calcRemain(
available?.expireTime ?? 0,
whiteLimit,
tempWhiteLimit,
)
const confirmSetLimit = async () => {
setSaving(true)
const res = await setWhiteLimitSu(tempWhiteLimit)
setSaving(false)
if (!res.success) {
toast.error(res.message)
return
}
const { code, msg } = res.data
if (code !== undefined && code < 1) {
toast.error(msg || "设置失败")
return
}
toast.success("白名单上限设置成功")
setIsDialogOpen(false)
loadLimit()
} }
return ( return (
@@ -95,7 +175,9 @@ export default function WhitelistSUPage() {
className="max-w-sm" className="max-w-sm"
onKeyDown={e => e.key === "Enter" && handleSaveIp()} onKeyDown={e => e.key === "Enter" && handleSaveIp()}
/> />
<Button onClick={handleSaveIp}>保存</Button> <Button onClick={handleSaveIp} disabled={saving}>
保存
</Button>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -110,15 +192,18 @@ export default function WhitelistSUPage() {
<DataTable<WhiteIpItem> <DataTable<WhiteIpItem>
data={list} data={list}
status="done" status={status}
columns={[ columns={[
{ accessorKey: "ip", header: "IP地址" },
{ {
accessorKey: "ip", accessorKey: "description",
header: "IP地址", header: "备注",
cell: ({ row }) => row.original.description || "-",
}, },
{ {
accessorKey: "createTime", accessorKey: "createTime",
header: "设置时间", header: "设置时间",
cell: ({ row }) => formatUnixTime(row.original.createTime),
}, },
{ {
accessorKey: "isLocked", accessorKey: "isLocked",
@@ -137,24 +222,25 @@ export default function WhitelistSUPage() {
header: "操作", header: "操作",
cell: ({ row }) => { cell: ({ row }) => {
const item = row.original const item = row.original
const isLocked = item.isLocked === "已锁定"
return ( return (
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => toggleLock(item)} onClick={() => handleLock(item)}
> >
{item.isLocked === "未锁定" ? ( {isLocked ? (
<Lock className="mr-1 h-3 w-3" />
) : (
<Unlock className="mr-1 h-3 w-3" /> <Unlock className="mr-1 h-3 w-3" />
) : (
<Lock className="mr-1 h-3 w-3" />
)} )}
{item.isLocked === "未锁定" ? "锁定" : "解锁"} {isLocked ? "解锁" : "锁定"}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => removeIp(item)} onClick={() => handleDelete(item)}
> >
<Trash2 className="mr-1 h-3 w-3" /> <Trash2 className="mr-1 h-3 w-3" />
删除 删除
@@ -168,56 +254,63 @@ export default function WhitelistSUPage() {
</div> </div>
</div> </div>
{/* 调整白名单上限弹窗 */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>调整白名单上限</DialogTitle> <DialogTitle>调整白名单上限</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-4"> <div className="flex flex-col items-start gap-4 py-4">
<div className="flex items-center gap-2">
<Button <div className="flex items-center gap-2">
variant="outline" <Button
size="sm" variant="outline"
onClick={decreaseLimit} size="sm"
disabled={tempWhiteLimit <= 1} className="focus:outline-none focus:ring-0"
> onClick={decreaseLimit}
<Minus className="h-4 w-4" /> disabled={tempWhiteLimit <= 1}
</Button> >
<span className="w-12 text-center font-medium"> <Minus className="h-4 w-4" />
{tempWhiteLimit} </Button>
</span> <span className="w-12 text-center font-medium">
<Button {tempWhiteLimit}
variant="outline" </span>
size="sm" <Button
onClick={increaseLimit} variant="outline"
disabled={tempWhiteLimit >= 20} size="sm"
> className="focus:outline-none focus:ring-0"
<Plus className="h-4 w-4" /> onClick={increaseLimit}
</Button> disabled={tempWhiteLimit >= 20}
</div> >
<div className="text-sm"> <Plus className="h-4 w-4" />
{tempDiscount === 10 ? ( </Button>
<span>
价格<span className="text-red-500">无折扣</span>
</span>
) : (
<span className="text-red-500">
当前已应用 {tempDiscount * 10}% 折扣
</span>
)}
</div>
</div> </div>
<div className="text-sm text-muted-foreground">
调整后到期时间:{newExpire.toLocaleString()} <div className="ml-8">
{tempDiscount === 10 ? (
<span>
价格<span className="text-red-500">无折扣</span>
</span>
) : (
<span className="text-red-500">
当前已应用 {tempDiscount * 10}% 折扣
</span>
)}
</div>
<div className="ml-8">
调整后到期时间:{new Date(newExpre).toLocaleString()}
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}> <Button variant="secondary" onClick={() => setIsDialogOpen(false)}>
关闭 关闭
</Button> </Button>
<Button onClick={confirmSetLimit}>确定</Button> <Button onClick={confirmSetLimit} disabled={saving}>
确定
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -4,7 +4,7 @@ import {
CLIENT_ID, CLIENT_ID,
CLIENT_SECRET, CLIENT_SECRET,
} from "@/lib/api" } from "@/lib/api"
import { clearSession, getUserToken } from "./token" import { getUserToken } from "./token"
type CallMode = "public" | "device" | "user" type CallMode = "public" | "device" | "user"
@@ -76,7 +76,14 @@ async function request<R = undefined>(
return { return {
success: false, success: false,
status: response.status, status: response.status,
message: json.message || json.error_description || "请求失败", code: typeof json.Code === "number" ? json.Code : undefined,
message:
json.message ||
json.Message ||
json.error_description ||
(typeof json.Code === "number"
? `请求失败(错误码 ${json.Code})`
: "请求失败"),
} }
} }
@@ -212,7 +219,6 @@ async function call<R = undefined>(
auth = `Bearer ${token}` auth = `Bearer ${token}`
} else if (mode === "device") { } else if (mode === "device") {
if (attempt > 1) { if (attempt > 1) {
// 上次请求 401,强制作废旧 token 再重新获取
deviceToken = null deviceToken = null
deviceTokenExpire = 0 deviceTokenExpire = 0
} }
@@ -229,14 +235,13 @@ async function call<R = undefined>(
const resp = await request<R>(url, { method, body, auth }) const resp = await request<R>(url, { method, body, auth })
if (!resp.success && resp.status === 401) { if (
if (mode === "user") { !resp.success &&
clearSession() resp.status === 401 &&
return resp mode === "device" &&
} attempt < maxAttempts
if (mode === "device" && attempt < maxAttempts) { ) {
continue continue
}
} }
return resp return resp
} }

View File

@@ -18,4 +18,4 @@ export async function fetchScoreList(
success: true, success: true,
data: Array.isArray(res.data?.data) ? res.data.data : [], data: Array.isArray(res.data?.data) ? res.data.data : [],
} }
} }

View File

@@ -160,12 +160,13 @@ export function toNumber(value: unknown): number {
} }
// 解析服务端分页信息,缺失字段兜底 // 解析服务端分页信息,缺失字段兜底
// 兼容两套字段命名:文档的 total/maxResults 与旧接口的 totalCount/pageSize
export function parsePager(raw: unknown, itemCount: number): Pager { export function parsePager(raw: unknown, itemCount: number): Pager {
const p = (raw ?? {}) as Record<string, unknown> const p = (raw ?? {}) as Record<string, unknown>
return { return {
currentPage: toNumber(p.currentPage) || 1, currentPage: toNumber(p.currentPage) || 1,
pageSize: toNumber(p.pageSize) || itemCount, pageSize: toNumber(p.maxResults ?? p.pageSize) || itemCount,
totalCount: toNumber(p.totalCount), totalCount: toNumber(p.total ?? p.totalCount),
totalPage: toNumber(p.totalPage), totalPage: toNumber(p.totalPage),
} }
} }
@@ -346,7 +347,9 @@ export async function fetchCxPackages(): Promise<ApiResponse<CxPackage[]>> {
cookie: document.cookie, cookie: document.cookie,
}) })
if (!res.success) return res if (!res.success) return res
const list = res.data?.data ?? res.data const raw = res.data ?? {}
// 文档结构为 data.d.packs;兼容 data 直接为数组的情况
const list = unwrapD(raw).packs ?? raw.data ?? raw
return { return {
success: true, success: true,
data: isArray<CxPackage>(list) ? list : [], data: isArray<CxPackage>(list) ? list : [],
@@ -367,9 +370,10 @@ export async function fetchCxOrders(): Promise<ApiResponse<CxOrder[]>> {
export async function fetchLangIpList( export async function fetchLangIpList(
query: Record<string, unknown>, query: Record<string, unknown>,
): Promise<ApiResponse<LangIpListResult>> { ): Promise<ApiResponse<LangIpListResult>> {
const res = await phpPost<RawHttpResponse>("/http/user/lang_ip_list", { const res = await phpPost<RawHttpResponse>("/http/user/lang_ip_list", {
cookie: document.cookie, cookie: document.cookie,
data: query, ...query,
}) })
if (!res.success) return res if (!res.success) return res
const d = unwrapD(res.data) const d = unwrapD(res.data)
@@ -383,9 +387,10 @@ export async function fetchLangIpList(
export async function fetchSwitchList( export async function fetchSwitchList(
query: Record<string, unknown>, query: Record<string, unknown>,
): Promise<ApiResponse<SwitchListResult>> { ): Promise<ApiResponse<SwitchListResult>> {
const res = await phpPost<RawHttpResponse>("/http/user/cx_switch", { const res = await phpPost<RawHttpResponse>("/http/user/cx_switch", {
cookie: document.cookie, cookie: document.cookie,
data: query, ...query,
}) })
if (!res.success) return res if (!res.success) return res
const d = unwrapD(res.data) const d = unwrapD(res.data)

View File

@@ -3,7 +3,7 @@ import type {
CreateOrderData, CreateOrderData,
CreateOrderRequest, CreateOrderRequest,
CreateTestAccountRequest, CreateTestAccountRequest,
MyOrderPage, MyOrderListResult,
MyOrderQueryParams, MyOrderQueryParams,
OrderItem, OrderItem,
OrderQueryParams, OrderQueryParams,
@@ -53,15 +53,43 @@ export async function fetchOrderPage(
) )
} }
// 退款订单分页列表 // ==================== 个人中心订单/退货订单 ====================
export async function fetchRefundOrders(
params: OrderQueryParams, // 响应结构:{ Code: 10000, data: [{ RowCount, List }] }
): Promise<ApiResponse<OrderItem[]>> { async function fetchMyOrderPage(
return await callByUser<OrderItem[]>( endpoint: string,
"/api/course/v1/order/RefundOrders", params: MyOrderQueryParams,
params, ): Promise<ApiResponse<MyOrderListResult>> {
"GET", const res = await callPublic<
) MyOrderListResult[] | MyOrderListResult | unknown
>(endpoint, params, "GET")
if (!res.success) return res
const data = res.data
const raw = Array.isArray(data) ? data[0] : data
const page =
raw && typeof raw === "object" ? (raw as MyOrderListResult) : null
return {
success: true,
data: {
RowCount: Number(page?.RowCount ?? 0),
List: Array.isArray(page?.List) ? page.List : [],
},
}
}
// 我的订单(订单管理)
export async function fetchMyOrders(
params: MyOrderQueryParams,
): Promise<ApiResponse<MyOrderListResult>> {
return fetchMyOrderPage("/user/ApiMyOrders", params)
}
// 我的退货订单
export async function fetchMyRefundOrders(
params: MyOrderQueryParams,
): Promise<ApiResponse<MyOrderListResult>> {
return fetchMyOrderPage("/user/ApiMyRefundOrders", params)
} }
// 优惠券订单分页列表 // 优惠券订单分页列表
@@ -108,43 +136,6 @@ export async function exportOrders(
) )
} }
async function fetchMyOrderPage(
endpoint: string,
params: MyOrderQueryParams,
): Promise<ApiResponse<MyOrderPage>> {
const res = await callPublic<MyOrderPage[] | MyOrderPage | unknown>(
endpoint,
params,
"GET",
)
if (!res.success) return res
const data = res.data
const raw = Array.isArray(data) ? data[0] : data
const page = raw && typeof raw === "object" ? (raw as MyOrderPage) : null
return {
success: true,
data: {
RowCount: Number(page?.RowCount ?? 0),
List: Array.isArray(page?.List) ? page.List : [],
},
}
}
// 我的订单(订单管理)
export async function fetchMyOrders(
params: MyOrderQueryParams,
): Promise<ApiResponse<MyOrderPage>> {
return fetchMyOrderPage("/user/ApiMyOrders", params)
}
// 我的退货订单
export async function fetchMyRefundOrders(
params: MyOrderQueryParams,
): Promise<ApiResponse<MyOrderPage>> {
return fetchMyOrderPage("/user/ApiMyRefundOrders", params)
}
export type RechargeOrderResult = { export type RechargeOrderResult = {
OrderInfo: { OrderInfo: {
OrderNo: string OrderNo: string

View File

@@ -30,7 +30,7 @@ export async function fetchWindowProducts(): Promise<
) )
} }
// 产品+套餐(用于账号管理筛选,接口来源:juipnet MyAccounts.cshtml) // 产品+套餐(用于账号管理筛选)
export async function fetchProductWithPackage(): Promise< export async function fetchProductWithPackage(): Promise<
ApiResponse<ProductItem[]> ApiResponse<ProductItem[]>
> { > {

View File

@@ -2,42 +2,50 @@ import type { ApiResponse } from "@/lib/api"
import type { import type {
HttpUsedLogItem, HttpUsedLogItem,
HttpUsedLogQuery, HttpUsedLogQuery,
HttpUsedLogsResult,
HttpUsedSummary, HttpUsedSummary,
} from "@/lib/models/record" } from "@/lib/models/record"
import { isArray, phpPost, toNumber, unwrapD } from "./http" import { isArray, parsePager, phpPost, toNumber, unwrapD } from "./http"
// ==================== HTTP 使用记录(php-api /http/user/*) ==================== // ==================== HTTP 使用记录(php-api /http/user/*) ====================
// TODO: 接口路径待后端确认,按现有 /http/user/* 命名规律推测。
// 包天包量使用概况
export async function fetchHttpUsedSummary(): Promise< export async function fetchHttpUsedSummary(): Promise<
ApiResponse<HttpUsedSummary> ApiResponse<HttpUsedSummary>
> { > {
const res = await phpPost("/http/user/pack_used_summary", { const res = await phpPost<Record<string, unknown>>("/http/user/dx_used", {
cookie: document.cookie, cookie: document.cookie,
}) })
if (!res.success) return res if (!res.success) return res
const d = unwrapD(res.data) const raw = res.data ?? {}
return { return {
success: true, success: true,
data: { data: {
todayShortPackIpUsed: toNumber(d.todayShortPackIpUsed), todayCoinPackIpPaid:
shortPackIpUsed: toNumber(d.shortPackIpUsed), typeof raw.todayCoinPackIpPaid === "string"
? raw.todayCoinPackIpPaid
: String(raw.todayCoinPackIpPaid ?? ""),
todayCoinPackIpUsed: toNumber(raw.todayCoinPackIpUsed),
coinPackIpUsed: toNumber(raw.coinPackIpUsed),
todayShortPackIpUsed: toNumber(raw.todayShortPackIpUsed),
shortPackIpUsed: toNumber(raw.shortPackIpUsed),
}, },
} }
} }
// 短效套餐使用记录(服务端分页)
export async function fetchHttpUsedLogs( export async function fetchHttpUsedLogs(
query: HttpUsedLogQuery, query: HttpUsedLogQuery,
): Promise<ApiResponse<HttpUsedLogItem[]>> { ): Promise<ApiResponse<HttpUsedLogsResult>> {
const res = await phpPost("/http/user/pack_used_log", { const res = await phpPost<Record<string, unknown>>("/http/user/dx_used_log", {
cookie: document.cookie, cookie: document.cookie,
data: query, data: query,
}) })
if (!res.success) return res if (!res.success) return res
const d = unwrapD(res.data) const d = unwrapD(res.data)
const list = d.items ?? d.list ?? d.data const items = isArray<HttpUsedLogItem>(d.items) ? d.items : []
return { return {
success: true, success: true,
data: isArray<HttpUsedLogItem>(list) ? list : [], data: { items, pager: parsePager(d.pager, items.length) },
} }
} }

View File

@@ -1,56 +1,118 @@
import type { ApiResponse } from "@/lib/api" import type { ApiResponse } from "@/lib/api"
import type { WhiteIpItem } from "@/lib/models/whitelist" import type { UnlimitedAvailable, WhiteIpItem } from "@/lib/models/whitelist"
import { isArray, phpPost, unwrapD } from "./http" import { isArray, phpPost, toNumber, unwrapD } from "./http"
// ==================== 白名单(php-api /http/user/*) ==================== type RawHttpResponse = Record<string, unknown>
// TODO: 后端接口路径待确认,以下路径按现有 /http/user/white_* 命名规律推测,
// 后端就绪后需按实际路径修正。
export async function fetchWhiteList(): Promise<ApiResponse<WhiteIpItem[]>> {
const res = await phpPost("/http/user/white_list", { export type WhiteActionResult = {
code?: number
msg?: string
}
async function postWhiteAction(
path: string,
data: Record<string, unknown>,
): Promise<ApiResponse<WhiteActionResult>> {
const res = await phpPost<RawHttpResponse>(path, {
cookie: document.cookie, cookie: document.cookie,
data,
}) })
if (!res.success) return res if (!res.success) return res
const d = unwrapD(res.data) const raw = res.data ?? {}
const list = d.list ?? d.data ?? res.data?.data
return { return {
success: true, success: true,
data: isArray<WhiteIpItem>(list) ? list : [], data: {
code: raw.code === undefined ? undefined : toNumber(raw.code),
msg: typeof raw.msg === "string" ? raw.msg : undefined,
},
} }
} }
export async function saveWhiteIp(ip: string): Promise<ApiResponse<undefined>> { // 白名单列表接口返回 data 直接为数组,兼容 { list: [...] } / { data: [...] }
return await phpPost("/http/user/white_save", { function parseList(raw: unknown): WhiteIpItem[] {
cookie: document.cookie, const value = raw as RawHttpResponse | undefined
ip, const list = Array.isArray(raw) ? raw : (value?.list ?? value?.data)
}) return isArray<WhiteIpItem>(list) ? list : []
} }
export async function deleteWhiteIp( // ==================== 白名单(普通) ====================
id: number,
): Promise<ApiResponse<undefined>> { export async function fetchWhiteList(): Promise<ApiResponse<WhiteIpItem[]>> {
return await phpPost("/http/user/white_delete", { const res = await phpPost<RawHttpResponse>("/http/user/white_list", {
cookie: document.cookie, cookie: document.cookie,
id,
}) })
if (!res.success) return res
return { success: true, data: parseList(res.data) }
} }
export async function lockWhiteIp( export function createWhiteIp(ip: string) {
id: number, return postWhiteAction("/http/user/create_white_ip", { ip })
locked: boolean,
): Promise<ApiResponse<undefined>> {
return await phpPost("/http/user/white_lock", {
cookie: document.cookie,
id,
locked: locked ? 1 : 0,
})
} }
export async function setWhiteLimitMax( export function lockWhiteIp(id: number, lock: 0 | 1) {
limit: number, return postWhiteAction("/http/user/lock_ip", { id, lock })
): Promise<ApiResponse<undefined>> { }
return await phpPost("/http/user/white_limit_set_su", {
cookie: document.cookie, export function deleteWhiteIp(id: number) {
limit, return postWhiteAction("/http/user/delete_ip", { id })
}) }
// ==================== 白名单(短效无限量) ====================
export async function fetchWhiteListSu(): Promise<ApiResponse<WhiteIpItem[]>> {
const res = await phpPost<RawHttpResponse>("/http/user/white_list_su", {
cookie: document.cookie,
})
if (!res.success) return res
return { success: true, data: parseList(res.data) }
}
export function createWhiteIpSu(ip: string) {
return postWhiteAction("/http/user/create_white_ip_su", { ip })
}
export function lockWhiteIpSu(id: number, lock: 0 | 1) {
return postWhiteAction("/http/user/lock_ip_su", { id, lock })
}
export function deleteWhiteIpSu(id: number) {
return postWhiteAction("/http/user/delete_ip_su", { id })
}
// 当前白名单数量
export async function fetchWhiteLimitSu(): Promise<ApiResponse<number>> {
const res = await phpPost<RawHttpResponse>("/http/user/white_limit_get_su", {
cookie: document.cookie,
})
if (!res.success) return res
return { success: true, data: toNumber(res.data?.data ?? res.data) }
}
// 调整白名单上限
export function setWhiteLimitSu(maxAmount: number) {
return postWhiteAction("/http/user/white_limit_set_su", { maxAmount })
}
// 获取最新套餐详情
export async function fetchUnlimitedAvailable(): Promise<
ApiResponse<UnlimitedAvailable>
> {
const res = await phpPost<RawHttpResponse>(
"/http/user/get_unlimited_available",
{ cookie: document.cookie },
)
if (!res.success) return res
// 文档结构为 data.d.{...};兼容 data 直接平铺字段的情况
const merged = { ...(res.data ?? {}), ...unwrapD(res.data) }
return {
success: true,
data: {
packId: toNumber(merged.packId),
poolType: toNumber(merged.poolType),
durationType: toNumber(merged.durationType),
usage: toNumber(merged.usage),
expireTime: toNumber(merged.expireTime),
},
}
} }

View File

@@ -20,10 +20,20 @@ export default function OrderFilterBar(props: {
filters: OrderFilters filters: OrderFilters
products: ProductItem[] products: ProductItem[]
showOrderType?: boolean showOrderType?: boolean
showPackageName?: boolean
onPatch: (patch: Partial<OrderFilters>) => void onPatch: (patch: Partial<OrderFilters>) => void
onSearch: () => void onSearch: () => void
onReset?: () => void
}) { }) {
const { filters, products, showOrderType = true, onPatch, onSearch } = props const {
filters,
products,
showOrderType = true,
showPackageName = true,
onPatch,
onSearch,
onReset,
} = props
return ( return (
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3"> <div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
@@ -84,22 +94,24 @@ export default function OrderFilterBar(props: {
</SelectContent> </SelectContent>
</Select> </Select>
<Select {showPackageName && (
value={filters.packageName} <Select
onValueChange={value => onPatch({ packageName: value })} value={filters.packageName}
> onValueChange={value => onPatch({ packageName: value })}
<SelectTrigger className="w-32"> >
<SelectValue placeholder="全部套餐" /> <SelectTrigger className="w-32">
</SelectTrigger> <SelectValue placeholder="全部套餐" />
<SelectContent> </SelectTrigger>
<SelectItem value="0">全部套餐</SelectItem> <SelectContent>
{PACKAGE_OPTIONS.map(pkg => ( <SelectItem value="0">全部套餐</SelectItem>
<SelectItem key={pkg} value={pkg}> {PACKAGE_OPTIONS.map(pkg => (
{pkg} <SelectItem key={pkg} value={pkg}>
</SelectItem> {pkg}
))} </SelectItem>
</SelectContent> ))}
</Select> </SelectContent>
</Select>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
@@ -115,6 +127,11 @@ export default function OrderFilterBar(props: {
<SearchIcon data-icon="inline-start" /> <SearchIcon data-icon="inline-start" />
查询 查询
</Button> </Button>
{onReset && (
<Button variant="outline" onClick={onReset}>
重置
</Button>
)}
</div> </div>
</div> </div>
) )

View File

@@ -1,5 +1,3 @@
"use client"
import { import {
ChevronLeftIcon, ChevronLeftIcon,
ChevronRightIcon, ChevronRightIcon,
@@ -42,21 +40,6 @@ function Pagination({
// 无数据时兜底为 1 页,避免除零与空分页器 // 无数据时兜底为 1 页,避免除零与空分页器
const totalPages = size > 0 ? Math.max(Math.ceil(total / size), 1) : 1 const totalPages = size > 0 ? Math.max(Math.ceil(total / size), 1) : 1
// 移动端检测:小屏幕减少页码按钮数量,避免溢出
const [isMobile, setIsMobile] = useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(max-width: 640px)").matches,
)
useEffect(() => {
const mql = window.matchMedia("(max-width: 640px)")
const handleChange = () => setIsMobile(mql.matches)
handleChange()
mql.addEventListener("change", handleChange)
return () => mql.removeEventListener("change", handleChange)
}, [])
// 同步外部 page 变化 // 同步外部 page 变化
useEffect(() => { useEffect(() => {
setCurrentPage(page) setCurrentPage(page)
@@ -64,13 +47,12 @@ function Pagination({
// 分页器逻辑 // 分页器逻辑
const generatePaginationItems = () => { const generatePaginationItems = () => {
// 桌面端最多显示7个页码,移动端最多显示5个,其余用省略号 // 最多显示7个页码,其余用省略号
const SIBLINGS = isMobile ? 0 : 1 // 当前页左右各显示的页码数 const SIBLINGS = 1 // 当前页左右各显示的页码数
const MAX_VISIBLE = isMobile ? 5 : 7
const DOTS = -1 // 省略号标记 const DOTS = -1 // 省略号标记
if (totalPages <= MAX_VISIBLE) { if (totalPages <= 7) {
// 总页数较少,全部显示 // 总页数少于7,全部显示
return Array.from({ length: totalPages }, (_, i) => i + 1) return Array.from({ length: totalPages }, (_, i) => i + 1)
} }
@@ -139,13 +121,8 @@ function Pagination({
const paginationItems = generatePaginationItems() const paginationItems = generatePaginationItems()
return ( return (
<div <div className={cn("flex flex-wrap items-center gap-4", className)}>
className={cn( <div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
"flex flex-wrap items-center justify-center gap-2 md:justify-start md:gap-4",
className,
)}
>
<div className="flex-none flex items-center gap-2 text-xs sm:text-sm text-muted-foreground">
共 {total} 条记录,每页 共 {total} 条记录,每页
<Select value={size.toString()} onValueChange={handlePageSizeChange}> <Select value={size.toString()} onValueChange={handlePageSizeChange}>
<SelectTrigger className="h-8 w-20"> <SelectTrigger className="h-8 w-20">
@@ -168,7 +145,7 @@ function Pagination({
<PaginationPrevious <PaginationPrevious
onClick={() => handlePageChange(currentPage - 1)} onClick={() => handlePageChange(currentPage - 1)}
className={ className={
currentPage <= 1 ? "opacity-50 pointer-events-none" : "" currentPage === 1 ? "opacity-50 pointer-events-none" : ""
} }
/> />
</PaginationItem> </PaginationItem>
@@ -198,7 +175,7 @@ function Pagination({
<PaginationNext <PaginationNext
onClick={() => handlePageChange(currentPage + 1)} onClick={() => handlePageChange(currentPage + 1)}
className={ className={
currentPage >= totalPages currentPage === totalPages
? "opacity-50 pointer-events-none" ? "opacity-50 pointer-events-none"
: "" : ""
} }
@@ -216,7 +193,6 @@ function PaginationLayout({
}: React.ComponentProps<"nav">) { }: React.ComponentProps<"nav">) {
return ( return (
<nav <nav
// role="navigation"
aria-label="pagination" aria-label="pagination"
data-slot="pagination" data-slot="pagination"
className={cn("flex-none", className)} className={cn("flex-none", className)}
@@ -232,10 +208,7 @@ function PaginationContent({
return ( return (
<ul <ul
data-slot="pagination-content" data-slot="pagination-content"
className={cn( className={cn("flex flex-row items-center gap-1", className)}
"flex flex-row flex-wrap items-center justify-center gap-1",
className,
)}
{...props} {...props}
/> />
) )
@@ -260,7 +233,7 @@ function PaginationLink({
data-slot="pagination-link" data-slot="pagination-link"
data-active={isActive} data-active={isActive}
className={cn( className={cn(
"inline-flex items-center justify-center text-xs sm:text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-8 w-8 sm:h-9 sm:w-9 rounded-md border border-input hover:bg-secondary hover:text-secondary-foreground", "inline-flex items-center justify-center text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-9 w-9 rounded-md border border-input hover:bg-secondary hover:text-secondary-foreground",
`bg-card`, `bg-card`,
isActive && isActive &&
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground", "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
@@ -309,10 +282,7 @@ function PaginationEllipsis({
<span <span
aria-hidden aria-hidden
data-slot="pagination-ellipsis" data-slot="pagination-ellipsis"
className={cn( className={cn("flex size-9 items-center justify-center", className)}
"flex size-8 sm:size-9 items-center justify-center",
className,
)}
{...props} {...props}
> >
<MoreHorizontalIcon className="size-4" /> <MoreHorizontalIcon className="size-4" />

View File

@@ -110,7 +110,7 @@ export default function HttpPage() {
const [customAmount, setCustomAmount] = useState("") const [customAmount, setCustomAmount] = useState("")
const [customIpAmount, setCustomIpAmount] = useState("") const [customIpAmount, setCustomIpAmount] = useState("")
// 未登录时跳转登录页,登录后回跳本页(参考 juipnet 产品页做法) // 未登录时跳转登录页,登录后回跳本页
const ensureLogin = () => { const ensureLogin = () => {
if (token !== null) return true if (token !== null) return true
toast.error("请先登录") toast.error("请先登录")

View File

@@ -3,24 +3,29 @@ import { toast } from "sonner"
import type { ApiResponse } from "@/lib/api" import type { ApiResponse } from "@/lib/api"
import { import {
DEFAULT_FILTERS, DEFAULT_FILTERS,
type MyOrderPage, type MyOrderListResult,
type MyOrderQueryParams, type MyOrderQueryParams,
type OrderFilters, type OrderFilters,
type OrderItem, type OrderItem,
} from "@/lib/models/order" } from "@/lib/models/order"
import type { ListStatus } from "./useList" import type { ListStatus } from "./useList"
const PAGE_SIZE = 10 const DEFAULT_SIZE = 10
// 订单管理/退货订单共用的筛选+分页逻辑(.NET /user/ApiMyOrders、ApiMyRefundOrders) // 订单管理/退货订单共用
// 接口返回全量列表({ RowCount, List }),本地做前端分页
export function useMyOrderPage( export function useMyOrderPage(
fetchPage: (params: MyOrderQueryParams) => Promise<ApiResponse<MyOrderPage>>, fetchPage: (
params: MyOrderQueryParams,
) => Promise<ApiResponse<MyOrderListResult>>,
mapFilters: (filters: OrderFilters) => Partial<MyOrderQueryParams>,
) { ) {
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS) const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
const [data, setData] = useState<OrderItem[]>([]) const [list, setList] = useState<OrderItem[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [status, setStatus] = useState<ListStatus>("load") const [status, setStatus] = useState<ListStatus>("load")
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [size, setSize] = useState(DEFAULT_SIZE)
const patch = useCallback( const patch = useCallback(
(partial: Partial<OrderFilters>) => (partial: Partial<OrderFilters>) =>
@@ -28,28 +33,31 @@ export function useMyOrderPage(
[], [],
) )
const load = useCallback(async () => { const fetchWith = useCallback(
setStatus("load") async (filterValues: OrderFilters) => {
const res = await fetchPage({ setStatus("load")
BTime: filters.bTime || undefined, const res = await fetchPage(mapFilters(filterValues))
ETime: filters.eTime || undefined, if (res.success) {
OrderType: setList(res.data.List)
filters.orderType === "0" ? undefined : Number(filters.orderType), setTotal(
ProductId: res.data.RowCount > 0 ? res.data.RowCount : res.data.List.length,
filters.productId === "0" ? undefined : Number(filters.productId), )
PackageId: filters.packageName === "0" ? undefined : filters.packageName, setStatus("done")
KeyWord: filters.keyword.trim() || undefined, } else {
}) setStatus("fail")
if (res.success) { toast.error(res.message)
setData(res.data.List) }
setTotal(res.data.RowCount) setPage(1)
setStatus("done") },
} else { [fetchPage, mapFilters],
setStatus("fail") )
toast.error(res.message)
} const load = useCallback(() => fetchWith(filters), [fetchWith, filters])
setPage(1)
}, [fetchPage, filters]) const reset = useCallback(() => {
setFilters(DEFAULT_FILTERS)
fetchWith(DEFAULT_FILTERS)
}, [fetchWith])
const loadedRef = useRef(false) const loadedRef = useRef(false)
useEffect(() => { useEffect(() => {
@@ -58,13 +66,28 @@ export function useMyOrderPage(
load() load()
}, [load]) }, [load])
// 接口返回全量列表(含 RowCount),本地做前端分页
const paged = useMemo( const paged = useMemo(
() => data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE), () => list.slice((page - 1) * size, page * size),
[data, page], [list, page, size],
) )
return { filters, patch, load, data, total, status, page, setPage, paged } const changeSize = useCallback((next: number) => {
} setSize(next)
setPage(1)
}, [])
export { PAGE_SIZE as MY_ORDER_PAGE_SIZE } return {
filters,
patch,
load,
reset,
list,
total,
status,
page,
size,
setPage,
changeSize,
paged,
}
}

View File

@@ -0,0 +1,77 @@
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import type { ApiResponse } from "@/lib/api"
import {
DEFAULT_FILTERS,
type OrderFilters,
type OrderItem,
type OrderPageData,
type OrderQueryParams,
} from "@/lib/models/order"
import type { ListStatus } from "./useList"
// 订单管理/退货订单共用的服务端分页逻辑
// (.NET /api/course/v1/order/Page、RefundOrders,返回 Data + TotalCount)
type FetchPage = (params: OrderQueryParams) => Promise<ApiResponse<OrderItem[]>>
export function useOrderPage(
fetchPage: FetchPage,
mapFilters: (filters: OrderFilters) => Partial<OrderQueryParams>,
) {
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
const [data, setData] = useState<OrderPageData>({
page: 1,
size: 10,
total: 0,
list: [],
})
const [status, setStatus] = useState<ListStatus>("load")
const patch = useCallback(
(partial: Partial<OrderFilters>) =>
setFilters(prev => ({ ...prev, ...partial })),
[],
)
const refresh = useCallback(
async (page: number, size: number) => {
setStatus("load")
const res = await fetchPage({
...mapFilters(filters),
PageIndex: page,
PageSize: size,
})
if (res.success) {
const totalPages = Math.max(Math.ceil((res.totalCount ?? 0) / size), 1)
// 数据变少导致当前页越界:回退到最后一页重新拉取
if (res.data.length === 0 && page > totalPages) {
await refresh(totalPages, size)
return
}
setData({
page,
size,
total: res.totalCount ?? 0,
list: res.data,
})
setStatus("done")
} else {
setStatus("fail")
toast.error(res.message)
}
},
[fetchPage, mapFilters, filters],
)
// 筛选查询:回到第 1 页
const load = useCallback(() => refresh(1, data.size), [refresh, data.size])
const loadedRef = useRef(false)
useEffect(() => {
if (loadedRef.current) return
loadedRef.current = true
refresh(1, data.size)
}, [refresh, data.size])
return { filters, patch, refresh, load, data, status }
}

View File

@@ -16,5 +16,5 @@ export type ScoreItem = {
RestAmount2: string RestAmount2: string
} }
// 服务端固定每页条数(接口不返回总条数) // 每页条数
export const SCORE_PAGE_SIZE = 50 export const SCORE_PAGE_SIZE = 50

View File

@@ -1,22 +1,42 @@
export type OrderItem = { export type OrderItem = {
TenantId: number
UserId: number
UserName: string
Id: number Id: number
OrderNo: string OrderNo: string
OrderType: number OrderType: number
ProductId: number ProductId: number
ProductName: string ProductName: string
PackageId: number
PackageName: string PackageName: string
DayPrice: number CouponId: number
CouponAmount: number
OrderName: string
OrderState: number
PayType: number
PayChannel: number
PayState: number
ConnectCount: number ConnectCount: number
OrderAmount: number
PaymentAmount: number
AccountPayAmount: number
OtherPayAmount: number
DayPrice: number
DayCount: number
ClientType: number
ChannelType: number
AccountCount: number AccountCount: number
Accounts: string Accounts: string
OrderAmount: number AccountPwd: string
CouponAmount: number
PaymentAmount: number
RefundCount: number RefundCount: number
RefundAmount: number RefundAmount: number
DayCount: number StartTime: string
EndTime: string
BackAmount: number
agent_id: number
CreateTime: string CreateTime: string
UpdateTime: string UpdateTime: string
DeleteTag: number
} }
export type CreateOrderRequest = { export type CreateOrderRequest = {
@@ -69,6 +89,13 @@ export type OrderQueryParams = {
PackageNames?: string[] PackageNames?: string[]
} }
export type OrderPageData = {
page: number
size: number
total: number
list: OrderItem[]
}
export type MyOrderQueryParams = { export type MyOrderQueryParams = {
BTime?: string BTime?: string
ETime?: string ETime?: string
@@ -78,7 +105,7 @@ export type MyOrderQueryParams = {
KeyWord?: string KeyWord?: string
} }
export type MyOrderPage = { export type MyOrderListResult = {
RowCount: number RowCount: number
List: OrderItem[] List: OrderItem[]
} }

View File

@@ -1,5 +1,3 @@
// 短效/长效套餐相关模型(与 php-api.juip.com 的 /http/user/* 接口对应)
// 短效套餐(已开通) // 短效套餐(已开通)
export type DxPackage = { export type DxPackage = {
id: number id: number
@@ -41,57 +39,83 @@ export type DxOrder = {
// 长效套餐(已开通) // 长效套餐(已开通)
export type CxPackage = { export type CxPackage = {
id: number id: number
name?: string
isAbroad: number | string isAbroad: number | string
isAbroad2?: number isAbroad2?: number
shareType: number | string shareType: number | string
ipAmount: number ipAmount: number
expireTime: string isExpired?: boolean | number | string
expireTime: number | string
transferable?: number
isRelayed?: number isRelayed?: number
} }
// 长效购买记录 // 长效购买记录
export type CxOrder = { export type CxOrder = {
id?: string
user_id?: string
order_no: string order_no: string
order_type: number | string order_type: number | string
game_type: string package_type?: string
ip_count: number durationType?: string | null
order_status?: number | string
money: number | string money: number | string
game_type: string
ip_count: number | string | null
order_id?: string
payCash?: string
serial?: string
status?: number | string
create_time: string create_time: string
update_time?: string
} }
// 已开通长效 IP // 已开通长效 IP
export type LangIpItem = { export type LangIpItem = {
id: number id: number
packId?: number packId?: number
isRelayed: number groupId?: number
isAbroad: number groupName?: string
gameId?: number
gameName?: string
ip: string ip: string
realIp: string index?: number
httpPort: number httpPort: number
socks5Port: number socks5Port: number
isp?: number
cityCode?: number
area: string area: string
bandwidth: number
lineType: number | string lineType: number | string
lineType2?: number lineType2?: number
shareType: number | string shareType: number | string
shareType2?: number shareType2?: number
state: number | string
username: string username: string
password: string password: string
expireTime: string isAbroad: number
isRelayed: number
expireTime: number | string
description?: string
bandwidth: number
isExpired: boolean | number | string isExpired: boolean | number | string
gameId?: number state: number | string
isp?: number popState?: number | string
popCityCode?: number
popIsp?: number
popArea?: string
realIp: string
} }
// 长效 IP 切换记录 // 长效 IP 切换记录
export type SwitchRecord = { export type SwitchRecord = {
id?: number
packId?: number
oldIp: string oldIp: string
oldArea: string oldArea: string
newIp: string newIp: string
newArea: string newArea: string
reason: string switchPrice?: number
createTime: string reason: number | string
createTime: number | string
} }
// 服务端分页信息 // 服务端分页信息
@@ -227,6 +251,27 @@ export const IP_STATE_LABELS: Record<number, string> = {
9: "封禁", 9: "封禁",
} }
// 线路共享类型
export const SHARE_TYPE_LABELS: Record<number, string> = {
1: "共享",
2: "独享",
}
// 线路质量类型
export const LINE_TYPE_LABELS: Record<number, string> = {
1: "普通",
2: "优质",
}
// 长效 IP 切换原因
export const SWITCH_REASON_LABELS: Record<number, string> = {
0: "系统自动切换",
1: "失效手动切换",
2: "免费手动切换",
5: "管理员免费切换",
6: "收费手动切换",
}
// ==================== 字段格式化 ==================== // ==================== 字段格式化 ====================
// 后端的 isExpired 可能返回 true/1/"1",也可能直接返回"已过期"/"未过期"字符串 // 后端的 isExpired 可能返回 true/1/"1",也可能直接返回"已过期"/"未过期"字符串
@@ -251,6 +296,21 @@ export function formatIpState(value: number | string): string {
return IP_STATE_LABELS[num] ?? String(value) return IP_STATE_LABELS[num] ?? String(value)
} }
export function formatShareType(value: number | string | undefined): string {
if (value === undefined || value === null || value === "") return ""
return SHARE_TYPE_LABELS[Number(value)] ?? String(value)
}
export function formatLineType(value: number | string | undefined): string {
if (value === undefined || value === null || value === "") return ""
return LINE_TYPE_LABELS[Number(value)] ?? String(value)
}
export function formatSwitchReason(value: number | string | undefined): string {
if (value === undefined || value === null || value === "") return ""
return SWITCH_REASON_LABELS[Number(value)] ?? String(value)
}
export function formatOrderTypeLabel( export function formatOrderTypeLabel(
labels: Record<number, string>, labels: Record<number, string>,
value: number | string, value: number | string,

View File

@@ -1,14 +1,20 @@
import type { Pager } from "./package"
export type HttpUsedSummary = { export type HttpUsedSummary = {
todayCoinPackIpPaid: string
todayCoinPackIpUsed: number
coinPackIpUsed: number
todayShortPackIpUsed: number todayShortPackIpUsed: number
shortPackIpUsed: number shortPackIpUsed: number
} }
export type HttpUsedLogItem = { export type HttpUsedLogItem = {
id?: number
packType: number packType: number
userIp: string userIp: string
ip: string ip: string
port: number port: number
createTime: string createTime: number | string
} }
export type HttpUsedLogQuery = { export type HttpUsedLogQuery = {
@@ -17,10 +23,16 @@ export type HttpUsedLogQuery = {
packType: number packType: number
} }
export type HttpUsedLogsResult = {
items: HttpUsedLogItem[]
pager: Pager
}
export const HTTP_USE_PACK_TYPE_LABELS: Record<number, string> = { export const HTTP_USE_PACK_TYPE_LABELS: Record<number, string> = {
12: "储值套餐", 12: "储值套餐",
21: "短效包天套餐", 21: "短效包天套餐",
22: "短效包量套餐", 22: "短效包量套餐",
23: "短效无限量套餐",
} }
export const HTTP_USE_PACK_TYPE_OPTIONS = [21, 22] export const HTTP_USE_PACK_TYPE_OPTIONS = [21, 22]

View File

@@ -1,6 +1,41 @@
export type WhiteIpItem = { export type WhiteIpItem = {
id: number id: number
ip: string ip: string
createTime: string description?: string
isLocked: string isLocked: string
createTime: number | string
}
// 短效无限量最新套餐详情
export type UnlimitedAvailable = {
packId: number
poolType: number
durationType: number
usage: number
expireTime: number
}
// 短效池类型
export const POOL_TYPE_LABELS: Record<number, string> = {
1: "经典池",
11: "白金池",
99: "全池",
}
// 有效期类型
export const DURATION_TYPE_LABELS: Record<number, string> = {
5: "1至5分钟",
25: "5至25分钟",
180: "25至180分钟",
360: "3至6小时",
}
export function formatPoolType(value: number | string | undefined): string {
if (value === undefined || value === null || value === "") return ""
return POOL_TYPE_LABELS[Number(value)] ?? String(value)
}
export function formatDurationType(value: number | string | undefined): string {
if (value === undefined || value === null || value === "") return ""
return DURATION_TYPE_LABELS[Number(value)] ?? String(value)
} }

View File

@@ -69,3 +69,19 @@ export function formatDate(value: string): string {
const day = String(date.getDate()).padStart(2, "0") const day = String(date.getDate()).padStart(2, "0")
return `${year}.${month}.${day}` return `${year}.${month}.${day}`
} }
// unix 时间戳(秒) → "YYYY-MM-DD HH:mm:ss";非数字内容原样返回
export function formatUnixTime(value: number | string | undefined): string {
if (value === undefined || value === null || value === "") return ""
const num = Number(value)
if (Number.isNaN(num) || num <= 0) return String(value)
const date = new Date(num * 1000)
if (Number.isNaN(date.getTime())) return String(value)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
const hours = String(date.getHours()).padStart(2, "0")
const minutes = String(date.getMinutes()).padStart(2, "0")
const seconds = String(date.getSeconds()).padStart(2, "0")
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}