Compare commits
5 Commits
aefbb8143e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc625ede8 | ||
|
|
160a9c9b97 | ||
|
|
6151add53f | ||
|
|
eee72ad74a | ||
|
|
a902f5be29 |
@@ -1,6 +1,7 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { fetchMyAccounts } from "@/api/account"
|
||||
import DataTable from "@/components/data-table"
|
||||
@@ -113,6 +114,7 @@ export default function ChannelsPage() {
|
||||
const [data, setData] = useState<MyAccountItem[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [page, setPage] = useState(1)
|
||||
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const selectedProduct = useMemo(
|
||||
() =>
|
||||
@@ -132,6 +134,7 @@ export default function ChannelsPage() {
|
||||
toast.error(res.message)
|
||||
}
|
||||
setPage(1)
|
||||
setSelection({})
|
||||
}, [])
|
||||
|
||||
const loadedRef = useRef(false)
|
||||
@@ -146,7 +149,10 @@ export default function ChannelsPage() {
|
||||
setQuery(next)
|
||||
load(toParams(next))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setQuery(INITIAL_QUERY)
|
||||
load(toParams(INITIAL_QUERY))
|
||||
}
|
||||
const paged = useMemo(
|
||||
() => data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[data, page],
|
||||
@@ -154,80 +160,121 @@ export default function ChannelsPage() {
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||
过期时间:
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<Select
|
||||
value={query.expiredDay}
|
||||
onValueChange={value => applyFilters({ expiredDay: value })}
|
||||
value={query.productId}
|
||||
onValueChange={value =>
|
||||
applyFilters({ productId: value, packageName: "0" })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
<SelectTrigger className="w-36">
|
||||
<SelectValue placeholder="全部产品" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{EXPIRED_DAY_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={String(option.value)}>
|
||||
{option.label}
|
||||
<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 variant="outline" onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={query.productId}
|
||||
onValueChange={value =>
|
||||
applyFilters({ productId: value, packageName: "0" })
|
||||
}
|
||||
>
|
||||
<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" />
|
||||
查询
|
||||
<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("续费功能开发中,敬请期待")}
|
||||
>
|
||||
续费
|
||||
</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>
|
||||
|
||||
@@ -237,6 +284,10 @@ export default function ChannelsPage() {
|
||||
data={paged}
|
||||
status={status}
|
||||
columns={columns}
|
||||
enableSelection
|
||||
rowSelection={selection}
|
||||
onSelectionChange={setSelection}
|
||||
getRowId={row => String(row.Id)}
|
||||
pagination={{
|
||||
page,
|
||||
size: PAGE_SIZE,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { fetchScoreList } from "@/api/funds"
|
||||
import DataTable from "@/components/data-table"
|
||||
@@ -58,18 +58,31 @@ export default function FundsPage() {
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
// 总数恰为 50 整数倍时最后一页也是 50 条,翻到空页后锁死最大页码
|
||||
const [maxPage, setMaxPage] = useState<number | null>(null)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
const load = useCallback(async (targetPage: number) => {
|
||||
const seq = ++seqRef.current
|
||||
setStatus("load")
|
||||
const res = await fetchScoreList(targetPage)
|
||||
if (res.success) {
|
||||
setData(res.data)
|
||||
setHasMore(res.data.length >= SCORE_PAGE_SIZE)
|
||||
setStatus("done")
|
||||
} else {
|
||||
console.log(res, 'datadatadatadatadata');
|
||||
|
||||
if (seq !== seqRef.current) return
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.length === 0 && targetPage > 1) {
|
||||
setMaxPage(targetPage - 1)
|
||||
setPage(targetPage - 1)
|
||||
load(targetPage - 1)
|
||||
return
|
||||
}
|
||||
setData(res.data)
|
||||
setHasMore(res.data.length >= SCORE_PAGE_SIZE)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -82,10 +95,13 @@ export default function FundsPage() {
|
||||
load(nextPage)
|
||||
}
|
||||
|
||||
// 接口不返回总条数:还有下一页时多加 1 条保证可翻页,否则按累计条数展示
|
||||
const total = hasMore
|
||||
? page * SCORE_PAGE_SIZE + 1
|
||||
: (page - 1) * SCORE_PAGE_SIZE + data.length
|
||||
const canNext = maxPage !== null ? page < maxPage : hasMore
|
||||
const total =
|
||||
maxPage !== null
|
||||
? maxPage * SCORE_PAGE_SIZE
|
||||
: canNext
|
||||
? page * SCORE_PAGE_SIZE + 1
|
||||
: (page - 1) * SCORE_PAGE_SIZE + data.length
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
@@ -1,34 +1,56 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { fetchHttpRechargeList } from "@/api/httpRecharge"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
|
||||
import {
|
||||
formatPayStatus,
|
||||
type HttpRechargeItem,
|
||||
} from "@/lib/models/httpRecharge"
|
||||
|
||||
export default function HttpRechargePage() {
|
||||
const [data, setData] = useState<HttpRechargeItem[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchHttpRechargeList()
|
||||
console.log(res, "fewewf")
|
||||
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
return
|
||||
}
|
||||
setData(res.data)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const columns = useMemo<ColumnDef<HttpRechargeItem>[]>(
|
||||
() => [
|
||||
{ accessorKey: "create_time", header: "交易时间" },
|
||||
{ accessorKey: "ju_money", header: "充值H币" },
|
||||
{ accessorKey: "pay_money", header: "支付金额" },
|
||||
{ accessorKey: "pay_type", header: "充值渠道" },
|
||||
{
|
||||
accessorKey: "pay_status",
|
||||
header: "支付状态",
|
||||
cell: ({ row }) => formatPayStatus(row.original.pay_status),
|
||||
},
|
||||
{ accessorKey: "order_no", header: "订单编号" },
|
||||
{ accessorKey: "ali_wx_no", header: "支付宝/微信订单号" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
|
||||
<DataTable<HttpRechargeItem>
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "create_time",
|
||||
header: "交易时间",
|
||||
},
|
||||
{
|
||||
accessorKey: "ju_money",
|
||||
header: "充值H币",
|
||||
},
|
||||
{
|
||||
accessorKey: "pay_type",
|
||||
header: "充值渠道",
|
||||
},
|
||||
{
|
||||
accessorKey: "pay_money",
|
||||
header: "充值金额",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<h3 className="font-semibold text-lg mb-4">HTTP 充值记录</h3>
|
||||
<DataTable data={data} status={status} columns={columns} />
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
|
||||
@@ -4,16 +4,29 @@ import AccountCell from "@/components/composites/accountCell"
|
||||
import OrderFilterBar from "@/components/composites/orderFilterBar"
|
||||
import DataTable from "@/components/data-table"
|
||||
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 {
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatOrderType,
|
||||
type MyOrderQueryParams,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
totalConnections,
|
||||
} 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>[] = [
|
||||
{
|
||||
accessorKey: "CreateTime",
|
||||
@@ -71,8 +84,19 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
|
||||
export default function OrdersPage() {
|
||||
const products = useProducts()
|
||||
const { filters, patch, load, total, status, page, setPage, paged } =
|
||||
useMyOrderPage(fetchMyOrders)
|
||||
const {
|
||||
filters,
|
||||
patch,
|
||||
load,
|
||||
reset,
|
||||
total,
|
||||
status,
|
||||
page,
|
||||
size,
|
||||
setPage,
|
||||
changeSize,
|
||||
paged,
|
||||
} = useMyOrderPage(fetchMyOrders, mapOrderFilters)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -80,7 +104,8 @@ export default function OrdersPage() {
|
||||
filters={filters}
|
||||
products={products}
|
||||
onPatch={patch}
|
||||
onSearch={() => load()}
|
||||
onSearch={load}
|
||||
onReset={reset}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
@@ -91,9 +116,10 @@ export default function OrdersPage() {
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: MY_ORDER_PAGE_SIZE,
|
||||
size,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: changeSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
@@ -1,5 +1,7 @@
|
||||
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 Page from "@/components/page"
|
||||
import {
|
||||
@@ -9,39 +11,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
formatPackType,
|
||||
HTTP_USE_PACK_TYPE_OPTIONS,
|
||||
type HttpUsedLogItem,
|
||||
} from "@/lib/models/record"
|
||||
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
|
||||
import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
|
||||
import { formatPackType, HTTP_USE_PACK_TYPE_OPTIONS } from "@/lib/models/record"
|
||||
import { estimateTotal, formatUnixTime } from "@/lib/utils"
|
||||
|
||||
// TODO: 后端使用记录接口就绪后,将 mock 替换为 src/api/record.ts 的
|
||||
// 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: "使用时间",
|
||||
},
|
||||
]
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50]
|
||||
|
||||
function StatCard(props: {
|
||||
label: string
|
||||
@@ -61,38 +35,81 @@ function StatCard(props: {
|
||||
}
|
||||
|
||||
export default function RecordPage() {
|
||||
const [summary, setSummary] = useState<HttpUsedSummary | null>(null)
|
||||
const [packType, setPackType] = useState(HTTP_USE_PACK_TYPE_OPTIONS[0])
|
||||
const [page, setPage] = useState(1)
|
||||
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(
|
||||
() => MOCK_USED_LOGS.filter(log => log.packType === packType),
|
||||
[packType],
|
||||
)
|
||||
const loadLogs = useCallback(async () => {
|
||||
setStatus("load")
|
||||
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(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
useEffect(() => {
|
||||
fetchHttpUsedSummary().then(res => {
|
||||
if (res.success) setSummary(res.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [loadLogs])
|
||||
|
||||
const handlePackTypeChange = (value: string) => {
|
||||
setPackType(Number(value))
|
||||
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 (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<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
|
||||
label="当日使用IP数量"
|
||||
value={MOCK_USED_SUMMARY.todayShortPackIpUsed}
|
||||
value={summary?.todayShortPackIpUsed ?? 0}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label="累计使用IP数量"
|
||||
value={MOCK_USED_SUMMARY.shortPackIpUsed}
|
||||
value={summary?.shortPackIpUsed ?? 0}
|
||||
accent="blue"
|
||||
/>
|
||||
</div>
|
||||
@@ -119,13 +136,14 @@ export default function RecordPage() {
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
data={items}
|
||||
status={status}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
total,
|
||||
sizeOptions: PAGE_SIZE_OPTIONS,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
|
||||
@@ -4,15 +4,27 @@ import AccountCell from "@/components/composites/accountCell"
|
||||
import OrderFilterBar from "@/components/composites/orderFilterBar"
|
||||
import DataTable from "@/components/data-table"
|
||||
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 {
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatOrderType,
|
||||
type MyOrderQueryParams,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
totalConnections,
|
||||
} 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>[] = [
|
||||
{
|
||||
accessorKey: "CreateTime",
|
||||
@@ -23,6 +35,11 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
accessorKey: "OrderNo",
|
||||
header: "订单编号",
|
||||
},
|
||||
{
|
||||
accessorKey: "OrderType",
|
||||
header: "类型",
|
||||
cell: ({ row }) => formatOrderType(row.original.OrderType),
|
||||
},
|
||||
{
|
||||
accessorKey: "ProductName",
|
||||
header: "产品",
|
||||
@@ -39,7 +56,7 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
accessorKey: "ConnectCount",
|
||||
header: "连接数",
|
||||
cell: ({ row }) => totalConnections(row.original),
|
||||
cell: ({ row }) => row.original.ConnectCount,
|
||||
},
|
||||
{
|
||||
accessorKey: "DayCount",
|
||||
@@ -65,8 +82,19 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
|
||||
export default function RefundOrdersPage() {
|
||||
const products = useProducts()
|
||||
const { filters, patch, load, total, status, page, setPage, paged } =
|
||||
useMyOrderPage(fetchMyRefundOrders)
|
||||
const {
|
||||
filters,
|
||||
patch,
|
||||
load,
|
||||
reset,
|
||||
total,
|
||||
status,
|
||||
page,
|
||||
size,
|
||||
setPage,
|
||||
changeSize,
|
||||
paged,
|
||||
} = useMyOrderPage(fetchMyRefundOrders, mapRefundFilters)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -75,7 +103,8 @@ export default function RefundOrdersPage() {
|
||||
products={products}
|
||||
showOrderType={false}
|
||||
onPatch={patch}
|
||||
onSearch={() => load()}
|
||||
onSearch={load}
|
||||
onReset={reset}
|
||||
/>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
@@ -86,9 +115,10 @@ export default function RefundOrdersPage() {
|
||||
columns={columns}
|
||||
pagination={{
|
||||
page,
|
||||
size: MY_ORDER_PAGE_SIZE,
|
||||
size,
|
||||
total,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: changeSize,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -21,12 +21,16 @@ import type {
|
||||
} from "@/lib/models/package"
|
||||
import {
|
||||
formatAbroad,
|
||||
formatExpired,
|
||||
formatIpState,
|
||||
formatLineType,
|
||||
formatOrderTypeLabel,
|
||||
formatShareType,
|
||||
formatSwitchReason,
|
||||
LONG_ORDER_TYPE_LABELS,
|
||||
parseExpired,
|
||||
} 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 LongSwitchDialog from "./long-switch-dialog"
|
||||
|
||||
@@ -214,9 +218,13 @@ export default function LongPage() {
|
||||
id: "bandwidth",
|
||||
header: "带宽",
|
||||
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",
|
||||
header: "状态",
|
||||
@@ -227,7 +235,11 @@ export default function LongPage() {
|
||||
header: "账号/密码",
|
||||
cell: ({ row }) => `${row.original.username}/${row.original.password}`,
|
||||
},
|
||||
{ accessorKey: "expireTime", header: "过期时间" },
|
||||
{
|
||||
accessorKey: "expireTime",
|
||||
header: "过期时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.expireTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "isExpired",
|
||||
header: "是否过期",
|
||||
@@ -274,14 +286,32 @@ export default function LongPage() {
|
||||
|
||||
const packageColumns = useMemo<ColumnDef<CxPackage>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "套餐名称",
|
||||
cell: ({ row }) => row.original.name || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "isAbroad",
|
||||
header: "套餐类型",
|
||||
cell: ({ row }) => formatAbroad(row.original.isAbroad),
|
||||
},
|
||||
{ accessorKey: "shareType", header: "模式" },
|
||||
{
|
||||
accessorKey: "shareType",
|
||||
header: "模式",
|
||||
cell: ({ row }) => formatShareType(row.original.shareType),
|
||||
},
|
||||
{ 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",
|
||||
header: "续费",
|
||||
@@ -337,8 +367,16 @@ export default function LongPage() {
|
||||
{ accessorKey: "oldArea", header: "原属地" },
|
||||
{ accessorKey: "newIp", header: "切换后IP" },
|
||||
{ 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),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
type ApiLinkConfig,
|
||||
type DxPackage,
|
||||
@@ -69,6 +77,151 @@ const SEPARATOR_OPTIONS = [
|
||||
{ value: "6", label: "自定义:" },
|
||||
]
|
||||
|
||||
const API_PARAM_DOCS: {
|
||||
name: string
|
||||
type: string
|
||||
required: string
|
||||
desc: string
|
||||
}[] = [
|
||||
{
|
||||
name: "pt",
|
||||
type: "int",
|
||||
required: "是",
|
||||
desc: "套餐类型,0或12-储值套餐,21-短效包天,22-短效包量,23-短效无限量,29-终身免费VIP套餐,31-长效套餐",
|
||||
},
|
||||
{ name: "num", type: "int", required: "是", desc: "提取IP数量" },
|
||||
{
|
||||
name: "gt",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "提取时长 0-默认 1-1分钟以上 2-2分钟以上 3-3分钟以上 4-4分钟以上",
|
||||
},
|
||||
{
|
||||
name: "regions",
|
||||
type: "string",
|
||||
required: "否",
|
||||
desc: "多个用英文逗号换行,不传表示随机",
|
||||
},
|
||||
{ name: "isp", type: "int", required: "是", desc: "0:不限 4:联通 2:电信" },
|
||||
{ name: "port", type: "int", required: "是", desc: "IP协议 1:HTTP 2:SOCKS5" },
|
||||
{
|
||||
name: "time",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "有效时长 1-0.1至1分钟 5-1至5分钟 25-5至25分钟 180-25至180分钟 360-3至6小时",
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "数据格式:1:TXT 2:JSON 3:HTML",
|
||||
},
|
||||
{ name: "pack", type: "int", required: "否", desc: "用户套餐ID" },
|
||||
{
|
||||
name: "ts",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "是否显示IP过期时间: 1显示 0不显示",
|
||||
},
|
||||
{
|
||||
name: "lb",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "换行符(1:\\r\\n 2:/br 3:\\r 4:\\n 5:\\t 6:自定义)",
|
||||
},
|
||||
{ name: "clb", type: "string", required: "否", desc: "自定义换行符" },
|
||||
{
|
||||
name: "sp",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "分隔符(1:竖线| 2:斜线/ 3:冒号 6:自定义)",
|
||||
},
|
||||
{ name: "csp", type: "string", required: "否", desc: "自定义分隔符" },
|
||||
{
|
||||
name: "distinct",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "去重选择(2:严格去重。0:不去重)",
|
||||
},
|
||||
{
|
||||
name: "aw",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "是否将调用者自动添加到IP白名单(0:不自动添加。1:自动添加)",
|
||||
},
|
||||
{
|
||||
name: "at",
|
||||
type: "int",
|
||||
required: "否",
|
||||
desc: "鉴权模式 1-白名单,2-白名单+账密严格鉴权,3-仅账密鉴权(无限量不可用)",
|
||||
},
|
||||
]
|
||||
|
||||
const RESPONSE_FIELD_DOCS: { name: string; type: string; desc: string }[] = [
|
||||
{ name: "success", type: "bool", desc: "是否成功" },
|
||||
{ name: "code", type: "int", desc: "0表示成功,其他参见《错误代码》表" },
|
||||
{ name: "ip", type: "string", desc: "IP" },
|
||||
{ name: "port", type: "int", desc: "端口号" },
|
||||
]
|
||||
|
||||
const ERROR_CODE_DOCS: { code: string; msg: string; desc: string }[] = [
|
||||
{
|
||||
code: "111",
|
||||
msg: "请在1秒后再次请求",
|
||||
desc: "提取链接请求太过频繁,超出限制",
|
||||
},
|
||||
{
|
||||
code: "113",
|
||||
msg: "请将192.168.0.0设置为白名单!",
|
||||
desc: "白名单未添加/白名单掉了",
|
||||
},
|
||||
{ code: "114", msg: "余额不足", desc: "账户金额消耗完毕" },
|
||||
{
|
||||
code: "115",
|
||||
msg: "请更换地区等条件重新生成api链接地址",
|
||||
desc: "没有资源或没有符合条件的数据",
|
||||
},
|
||||
{
|
||||
code: "116",
|
||||
msg: "您的套餐今日已到达上限!",
|
||||
desc: "套餐内IP数量消耗完毕",
|
||||
},
|
||||
{
|
||||
code: "117",
|
||||
msg: "您的套餐pack传参有误!请检测您现在的ip是否在套餐所在账户!",
|
||||
desc: "检测本地白名单是不是在账户下",
|
||||
},
|
||||
{
|
||||
code: "118",
|
||||
msg: "您的账户异常,请联系客服!",
|
||||
desc: "账户处于被禁用状态",
|
||||
},
|
||||
{ code: "121", msg: "您的该套餐已经过期了!", desc: "套餐过期" },
|
||||
{
|
||||
code: "401",
|
||||
msg: "IP:1.1.1.1:2000 Authentication failed!",
|
||||
desc: "白名单错误/使用的IP已经过期",
|
||||
},
|
||||
{
|
||||
code: "403",
|
||||
msg: "Sorry!The site has been banned!",
|
||||
desc: "客户目标网站异常,联系客服处理",
|
||||
},
|
||||
]
|
||||
|
||||
const RESPONSE_SAMPLE = `{
|
||||
"code": 0,
|
||||
"msg": "提示信息",
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"ip": "49.68.68.197", // 隧道ip(代理ip)
|
||||
"port": 33220, // 代理端口
|
||||
"expireTime": "2019-05-24 08:58:31"
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
type FieldRowProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
@@ -179,8 +332,10 @@ export default function ShortApiLink({ packages }: { packages: DxPackage[] }) {
|
||||
push("t", token.token)
|
||||
}
|
||||
push("at", config.at)
|
||||
push("sp", config.sp)
|
||||
push("csp", config.csp)
|
||||
if (config.at !== 1) {
|
||||
push("sp", config.sp)
|
||||
push("csp", config.csp)
|
||||
}
|
||||
if (regions.length > 0) push("regions", regions.join(","))
|
||||
return `${API_LINK_BASE}?${params.join("&")}`
|
||||
}
|
||||
@@ -431,7 +586,7 @@ export default function ShortApiLink({ packages }: { packages: DxPackage[] }) {
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
{config.at !== 1 && !isUnlimited && (
|
||||
{!isUnlimited && (
|
||||
<FieldRow label="分隔符">
|
||||
<RadioGroup
|
||||
value={String(config.sp)}
|
||||
@@ -485,6 +640,81 @@ export default function ShortApiLink({ packages }: { packages: DxPackage[] }) {
|
||||
{currentPackage.durationType}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 flex flex-col gap-3">
|
||||
<h4 className="font-semibold">请求参数注释</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>必选</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{API_PARAM_DOCS.map(param => (
|
||||
<TableRow key={param.name}>
|
||||
<TableCell className="font-mono">{param.name}</TableCell>
|
||||
<TableCell>{param.type}</TableCell>
|
||||
<TableCell>{param.required}</TableCell>
|
||||
<TableCell className="whitespace-normal min-w-96">
|
||||
{param.desc}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 flex flex-col gap-3">
|
||||
<h4 className="font-semibold">返回结果示例</h4>
|
||||
<pre className="rounded-md bg-gray-50 border border-gray-200 p-3 text-xs overflow-x-auto text-gray-700">
|
||||
<code>{RESPONSE_SAMPLE}</code>
|
||||
</pre>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-24">名称</TableHead>
|
||||
<TableHead className="w-24">类型</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{RESPONSE_FIELD_DOCS.map(field => (
|
||||
<TableRow key={field.name}>
|
||||
<TableCell className="font-mono">{field.name}</TableCell>
|
||||
<TableCell>{field.type}</TableCell>
|
||||
<TableCell className="whitespace-normal">
|
||||
{field.desc}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-dashed border-gray-200 p-4 flex flex-col gap-3">
|
||||
<h4 className="font-semibold">错误代码</h4>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-32">错误编码</TableHead>
|
||||
<TableHead>错误提示</TableHead>
|
||||
<TableHead>说明</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ERROR_CODE_DOCS.map(err => (
|
||||
<TableRow key={err.code}>
|
||||
<TableCell className="font-mono">code:{err.code}</TableCell>
|
||||
<TableCell>{err.msg}</TableCell>
|
||||
<TableCell className="whitespace-normal">{err.desc}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function ShortPage() {
|
||||
const loadPackages = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchDxPackages()
|
||||
console.log(res, "resresresres")
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
return
|
||||
@@ -44,6 +45,7 @@ export default function ShortPage() {
|
||||
const loadOrders = useCallback(async () => {
|
||||
setOrderStatus("load")
|
||||
const res = await fetchDxOrders()
|
||||
|
||||
if (!res.success) {
|
||||
setOrderStatus("fail")
|
||||
return
|
||||
|
||||
@@ -6,8 +6,6 @@ import Page from "@/components/page"
|
||||
import { useList } from "@/lib/hooks/useList"
|
||||
import type { RosOrderItem } from "@/lib/models/rosorder"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
|
||||
const columns: ColumnDef<RosOrderItem>[] = [
|
||||
{
|
||||
accessorKey: "username",
|
||||
@@ -52,9 +50,8 @@ const columns: ColumnDef<RosOrderItem>[] = [
|
||||
export default function RosorderPage() {
|
||||
const { data, status } = useList(fetchRosOrderList)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
// 接口返回全量列表,本地做前端分页
|
||||
const paged = useMemo(
|
||||
() => data.slice((page - 1) * pageSize, page * pageSize),
|
||||
[data, page, pageSize],
|
||||
|
||||
@@ -1,29 +1,89 @@
|
||||
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 Page from "@/components/page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { WhiteIpItem } from "@/lib/models/whitelist"
|
||||
|
||||
// TODO: 后端白名单接口就绪后,将 saveWhiteIp/fetchWhiteList 接入(见 src/api/whitelist.ts)
|
||||
import { formatUnixTime } from "@/lib/utils"
|
||||
|
||||
export default function WhitelistPage() {
|
||||
const [ip, setIp] = useState("")
|
||||
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()
|
||||
if (!value) return
|
||||
// TODO: await saveWhiteIp(value)
|
||||
setList(prev => [
|
||||
{ id: Date.now(), ip: value, createTime: "", isLocked: "未锁定" },
|
||||
...prev,
|
||||
])
|
||||
if (!value) {
|
||||
toast.error("请输入IP地址")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
await runAction(() => createWhiteIp(value), "添加成功")
|
||||
setSaving(false)
|
||||
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 (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
@@ -36,7 +96,9 @@ export default function WhitelistPage() {
|
||||
placeholder="请输入IP地址并点击保存白名单"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -48,15 +110,18 @@ export default function WhitelistPage() {
|
||||
|
||||
<DataTable<WhiteIpItem>
|
||||
data={list}
|
||||
status="done"
|
||||
status={status}
|
||||
columns={[
|
||||
{ accessorKey: "ip", header: "IP地址" },
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP地址",
|
||||
accessorKey: "description",
|
||||
header: "备注",
|
||||
cell: ({ row }) => row.original.description || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "设置时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.createTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "isLocked",
|
||||
@@ -75,41 +140,25 @@ export default function WhitelistPage() {
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
// TODO: 接入 lockWhiteIp / deleteWhiteIp
|
||||
const isLocked = item.isLocked === "已锁定"
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setList(prev =>
|
||||
prev.map(i =>
|
||||
i.id === item.id
|
||||
? {
|
||||
...i,
|
||||
isLocked:
|
||||
i.isLocked === "未锁定"
|
||||
? "已锁定"
|
||||
: "未锁定",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
)
|
||||
}
|
||||
onClick={() => handleLock(item)}
|
||||
>
|
||||
{item.isLocked === "未锁定" ? (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
{isLocked ? (
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{item.isLocked === "未锁定" ? "锁定" : "解锁"}
|
||||
{isLocked ? "解锁" : "锁定"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setList(prev => prev.filter(i => i.id !== item.id))
|
||||
}
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
删除
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
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 Page from "@/components/page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -12,75 +23,144 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { WhiteIpItem } from "@/lib/models/whitelist"
|
||||
|
||||
// TODO: 后端白名单接口就绪后,将 fetchWhiteList / setWhiteLimitMax 接入(见 src/api/whitelist.ts)
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { UnlimitedAvailable, WhiteIpItem } from "@/lib/models/whitelist"
|
||||
import { formatUnixTime } from "@/lib/utils"
|
||||
|
||||
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() {
|
||||
const [ip, setIp] = useState("")
|
||||
const [list, setList] = useState<WhiteIpItem[]>([])
|
||||
const [whiteLimit, setWhiteLimit] = useState(5)
|
||||
const [tempWhiteLimit, setTempWhiteLimit] = useState(5)
|
||||
const [tempDiscount, setTempDiscount] = useState(10)
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [whiteLimit, setWhiteLimit] = useState(0)
|
||||
const [available, setAvailable] = useState<UnlimitedAvailable | null>(null)
|
||||
const [tempWhiteLimit, setTempWhiteLimit] = useState(1)
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 调整后到期时间(演示用固定 +30 天)
|
||||
const newExpire = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
||||
const load = useCallback(async () => {
|
||||
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 clamped = clampLimit(value)
|
||||
setTempWhiteLimit(clamped)
|
||||
setTempDiscount(discountOf(clamped))
|
||||
}
|
||||
const loadLimit = useCallback(async () => {
|
||||
const res = await fetchWhiteLimitSu()
|
||||
if (res.success) setWhiteLimit(res.data)
|
||||
}, [])
|
||||
|
||||
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 = () => {
|
||||
setTempWhiteLimit(whiteLimit)
|
||||
setTempDiscount(discountOf(whiteLimit))
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
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 confirmSetLimit = () => {
|
||||
setWhiteLimit(tempWhiteLimit)
|
||||
setIsDialogOpen(false)
|
||||
// TODO: await setWhiteLimitMax(tempWhiteLimit)
|
||||
}
|
||||
|
||||
const handleSaveIp = () => {
|
||||
const handleSaveIp = async () => {
|
||||
const value = ip.trim()
|
||||
if (!value) return
|
||||
// TODO: await saveWhiteIp(value)
|
||||
setList(prev => [
|
||||
{ id: Date.now(), ip: value, createTime: "", isLocked: "未锁定" },
|
||||
...prev,
|
||||
])
|
||||
if (!value) {
|
||||
toast.error("请输入IP地址")
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
await runAction(() => createWhiteIpSu(value), "添加成功")
|
||||
setSaving(false)
|
||||
setIp("")
|
||||
}
|
||||
|
||||
const toggleLock = (item: WhiteIpItem) => {
|
||||
// TODO: await lockWhiteIp(item.id, item.isLocked !== "已锁定")
|
||||
setList(prev =>
|
||||
prev.map(i =>
|
||||
i.id === item.id
|
||||
? {
|
||||
...i,
|
||||
isLocked: i.isLocked === "未锁定" ? "已锁定" : "未锁定",
|
||||
}
|
||||
: i,
|
||||
),
|
||||
const handleLock = (item: WhiteIpItem) => {
|
||||
const locked = item.isLocked === "已锁定"
|
||||
runAction(
|
||||
() => lockWhiteIpSu(item.id, locked ? 0 : 1),
|
||||
locked ? "解锁成功" : "锁定成功",
|
||||
)
|
||||
}
|
||||
|
||||
const removeIp = (item: WhiteIpItem) => {
|
||||
// TODO: await deleteWhiteIp(item.id)
|
||||
setList(prev => prev.filter(i => i.id !== item.id))
|
||||
const handleDelete = (item: WhiteIpItem) => {
|
||||
runAction(() => deleteWhiteIpSu(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 (
|
||||
@@ -95,7 +175,9 @@ export default function WhitelistSUPage() {
|
||||
className="max-w-sm"
|
||||
onKeyDown={e => e.key === "Enter" && handleSaveIp()}
|
||||
/>
|
||||
<Button onClick={handleSaveIp}>保存</Button>
|
||||
<Button onClick={handleSaveIp} disabled={saving}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -110,15 +192,18 @@ export default function WhitelistSUPage() {
|
||||
|
||||
<DataTable<WhiteIpItem>
|
||||
data={list}
|
||||
status="done"
|
||||
status={status}
|
||||
columns={[
|
||||
{ accessorKey: "ip", header: "IP地址" },
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP地址",
|
||||
accessorKey: "description",
|
||||
header: "备注",
|
||||
cell: ({ row }) => row.original.description || "-",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "设置时间",
|
||||
cell: ({ row }) => formatUnixTime(row.original.createTime),
|
||||
},
|
||||
{
|
||||
accessorKey: "isLocked",
|
||||
@@ -137,24 +222,25 @@ export default function WhitelistSUPage() {
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
const isLocked = item.isLocked === "已锁定"
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleLock(item)}
|
||||
onClick={() => handleLock(item)}
|
||||
>
|
||||
{item.isLocked === "未锁定" ? (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
{isLocked ? (
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{item.isLocked === "未锁定" ? "锁定" : "解锁"}
|
||||
{isLocked ? "解锁" : "锁定"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeIp(item)}
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
删除
|
||||
@@ -168,56 +254,63 @@ export default function WhitelistSUPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 调整白名单上限弹窗 */}
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>调整白名单上限</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={decreaseLimit}
|
||||
disabled={tempWhiteLimit <= 1}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-center font-medium">
|
||||
{tempWhiteLimit}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={increaseLimit}
|
||||
disabled={tempWhiteLimit >= 20}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
{tempDiscount === 10 ? (
|
||||
<span>
|
||||
价格<span className="text-red-500">无折扣</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500">
|
||||
当前已应用 {tempDiscount * 10}% 折扣
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-4 py-4">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="focus:outline-none focus:ring-0"
|
||||
onClick={decreaseLimit}
|
||||
disabled={tempWhiteLimit <= 1}
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="w-12 text-center font-medium">
|
||||
{tempWhiteLimit}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="focus:outline-none focus:ring-0"
|
||||
onClick={increaseLimit}
|
||||
disabled={tempWhiteLimit >= 20}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</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>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
<Button variant="secondary" onClick={() => setIsDialogOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={confirmSetLimit}>确定</Button>
|
||||
<Button onClick={confirmSetLimit} disabled={saving}>
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { MyAccountItem, MyAccountQueryParams } from "@/lib/models/account"
|
||||
import { callPublic } from "./base"
|
||||
import { callByUserCookie } from "./base"
|
||||
|
||||
export async function fetchMyAccounts(
|
||||
params: MyAccountQueryParams,
|
||||
): Promise<ApiResponse<MyAccountItem[]>> {
|
||||
const res = await callPublic<MyAccountItem[] | unknown>(
|
||||
const res = await callByUserCookie<MyAccountItem[] | unknown>(
|
||||
"/user/ApiMyAccounts",
|
||||
params,
|
||||
"GET",
|
||||
|
||||
100
src/api/base.ts
100
src/api/base.ts
@@ -4,9 +4,27 @@ import {
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
} from "@/lib/api"
|
||||
import { clearSession, getUserToken } from "./token"
|
||||
import { clearCookie, clearSession, getUserToken } from "./token"
|
||||
|
||||
type CallMode = "public" | "device" | "user"
|
||||
type CallMode = "public" | "device" | "user" | "cookie"
|
||||
|
||||
// ==================== 登录态失效全局处理 ====================
|
||||
// user/cookie 模式请求未登录、收到 401(token 失效)或 302(后端跳登录页)时:
|
||||
// 清除本地会话并跳转登录页,带 redirect 参数便于登录后回跳。
|
||||
// 与 logout() 及 juipnet 老项目行为保持一致。
|
||||
let redirectingToLogin = false
|
||||
|
||||
function handleUnauthorized() {
|
||||
if (redirectingToLogin) return
|
||||
clearSession()
|
||||
clearCookie("token")
|
||||
clearCookie("userInfo")
|
||||
const current = window.location.pathname
|
||||
if (current !== "/login") {
|
||||
redirectingToLogin = true
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(current)}`
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 15_000
|
||||
|
||||
@@ -45,8 +63,20 @@ async function request<R = undefined>(
|
||||
headers,
|
||||
body: init.body,
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
})
|
||||
|
||||
// 登录态失效时后端会把受保护接口 302 到登录页(如 /User/WebLogin)。
|
||||
// manual 模式不自动跟随,此处识别重定向并返回 302,
|
||||
// 由 call() 在 user/cookie 模式下统一触发 handleUnauthorized 跳登录页。
|
||||
if (response.type === "opaqueredirect") {
|
||||
return {
|
||||
success: false,
|
||||
status: 302,
|
||||
message: "登录状态已失效,请重新登录",
|
||||
}
|
||||
}
|
||||
|
||||
const responseHeaders = captureHeaders(response)
|
||||
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
||||
|
||||
@@ -76,7 +106,14 @@ async function request<R = undefined>(
|
||||
return {
|
||||
success: false,
|
||||
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})`
|
||||
: "请求失败"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +138,13 @@ async function request<R = undefined>(
|
||||
return { success: true, data: json, headers: responseHeaders }
|
||||
}
|
||||
|
||||
throw new Error(`无法解析响应数据: ${contentType}`)
|
||||
// 其他内容类型(如 text/html 错误页)无法按 API 格式解析,
|
||||
// 保留真实 HTTP 状态码返回,便于上层识别 401/3xx 等登录态问题
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: `无法解析响应数据: ${contentType}`,
|
||||
}
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
@@ -203,6 +246,7 @@ async function call<R = undefined>(
|
||||
if (mode === "user") {
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
handleUnauthorized()
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
@@ -210,9 +254,20 @@ async function call<R = undefined>(
|
||||
}
|
||||
}
|
||||
auth = `Bearer ${token}`
|
||||
} else if (mode === "cookie") {
|
||||
// cookie 模式:凭证走浏览器 cookie(老 /user/* 接口),
|
||||
// 但登录态判断仍以本地 token 为准,缺失时同样触发跳登录
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
handleUnauthorized()
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "未登录或会话已过期",
|
||||
}
|
||||
}
|
||||
} else if (mode === "device") {
|
||||
if (attempt > 1) {
|
||||
// 上次请求 401,强制作废旧 token 再重新获取
|
||||
deviceToken = null
|
||||
deviceTokenExpire = 0
|
||||
}
|
||||
@@ -229,14 +284,23 @@ async function call<R = undefined>(
|
||||
|
||||
const resp = await request<R>(url, { method, body, auth })
|
||||
|
||||
if (!resp.success && resp.status === 401) {
|
||||
if (mode === "user") {
|
||||
clearSession()
|
||||
return resp
|
||||
}
|
||||
if (mode === "device" && attempt < maxAttempts) {
|
||||
continue
|
||||
}
|
||||
if (
|
||||
!resp.success &&
|
||||
resp.status === 401 &&
|
||||
mode === "device" &&
|
||||
attempt < maxAttempts
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 会话失效的三种表现:本地无 token(前面已处理)、后端 401、
|
||||
// 或后端 302 重定向到登录页(如 /user/* 过期跳 /User/WebLogin)
|
||||
if (
|
||||
!resp.success &&
|
||||
(resp.status === 401 || resp.status === 302) &&
|
||||
(mode === "user" || mode === "cookie")
|
||||
) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
@@ -278,4 +342,12 @@ async function callByUser<R = undefined>(
|
||||
return call<R>("user", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callPublic }
|
||||
async function callByUserCookie<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
return call<R>("cookie", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callByUserCookie, callPublic }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { ApiIndexItem } from "@/lib/models/dashboard"
|
||||
import { callPublic } from "./base"
|
||||
import { callByUserCookie } from "./base"
|
||||
|
||||
export async function fetchApiIndex(): Promise<
|
||||
ApiResponse<ApiIndexItem | null>
|
||||
> {
|
||||
const res = await callPublic<ApiIndexItem | ApiIndexItem[]>(
|
||||
const res = await callByUserCookie<ApiIndexItem | ApiIndexItem[]>(
|
||||
"/user/ApiIndex",
|
||||
undefined,
|
||||
"GET",
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { ScoreItem } from "@/lib/models/funds"
|
||||
import { phpPost } from "./http"
|
||||
import { phpFetch } from "./http"
|
||||
|
||||
export async function fetchScoreList(
|
||||
page: number,
|
||||
): Promise<ApiResponse<ScoreItem[]>> {
|
||||
const res = await phpPost<ScoreItem[] | unknown>("/user/UserScore/getList", {
|
||||
cookie: document.cookie,
|
||||
page,
|
||||
})
|
||||
const query = page > 1 ? `?page=${page}` : ""
|
||||
const res = await phpFetch<ScoreItem[] | { data?: unknown }>(
|
||||
"POST",
|
||||
`/user/UserScore/getList${query}`,
|
||||
{ cookie: document.cookie },
|
||||
{ credentials: "include" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
return { success: true, data: Array.isArray(res.data) ? res.data : [] }
|
||||
if (Array.isArray(res.data)) return { success: true, data: res.data }
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(res.data?.data) ? res.data.data : [],
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
CreateOrderResult,
|
||||
DxwxlData,
|
||||
GameCity,
|
||||
GameItem,
|
||||
GameOrderData,
|
||||
@@ -61,7 +62,9 @@ export async function phpFetch<T = RawHttpResponse>(
|
||||
message: "请求失败",
|
||||
}
|
||||
}
|
||||
const data = (await response.json()) as T
|
||||
const text = await response.text()
|
||||
// 老 PHP 接口异常时可能返回空响应体,直接 json() 会抛错
|
||||
const data = text ? (JSON.parse(text) as T) : ({} as T)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
@@ -158,12 +161,13 @@ export function toNumber(value: unknown): number {
|
||||
}
|
||||
|
||||
// 解析服务端分页信息,缺失字段兜底
|
||||
// 兼容两套字段命名:文档的 total/maxResults 与旧接口的 totalCount/pageSize
|
||||
export function parsePager(raw: unknown, itemCount: number): Pager {
|
||||
const p = (raw ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
currentPage: toNumber(p.currentPage) || 1,
|
||||
pageSize: toNumber(p.pageSize) || itemCount,
|
||||
totalCount: toNumber(p.totalCount),
|
||||
pageSize: toNumber(p.maxResults ?? p.pageSize) || itemCount,
|
||||
totalCount: toNumber(p.total ?? p.totalCount),
|
||||
totalPage: toNumber(p.totalPage),
|
||||
}
|
||||
}
|
||||
@@ -209,6 +213,25 @@ export async function calcGamePrice(
|
||||
return calcOrderPrice(orderInfo as unknown as PackageOrderInfo)
|
||||
}
|
||||
|
||||
export async function calcUnlimitedPrice(
|
||||
data: DxwxlData,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/calc_price", {
|
||||
order_type: 2,
|
||||
money: 1,
|
||||
pay_type: 1,
|
||||
data,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const priceValue =
|
||||
typeof raw.price === "number" ? raw.price : unwrapD(raw).price
|
||||
return {
|
||||
success: true,
|
||||
data: typeof priceValue === "number" ? priceValue : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export async function calcOrderPrice(
|
||||
orderInfo: PackageOrderInfo,
|
||||
): Promise<ApiResponse<number>> {
|
||||
@@ -344,7 +367,9 @@ export async function fetchCxPackages(): Promise<ApiResponse<CxPackage[]>> {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
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 {
|
||||
success: true,
|
||||
data: isArray<CxPackage>(list) ? list : [],
|
||||
@@ -365,9 +390,10 @@ export async function fetchCxOrders(): Promise<ApiResponse<CxOrder[]>> {
|
||||
export async function fetchLangIpList(
|
||||
query: Record<string, unknown>,
|
||||
): Promise<ApiResponse<LangIpListResult>> {
|
||||
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/lang_ip_list", {
|
||||
cookie: document.cookie,
|
||||
data: query,
|
||||
...query,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
@@ -381,9 +407,10 @@ export async function fetchLangIpList(
|
||||
export async function fetchSwitchList(
|
||||
query: Record<string, unknown>,
|
||||
): Promise<ApiResponse<SwitchListResult>> {
|
||||
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/cx_switch", {
|
||||
cookie: document.cookie,
|
||||
data: query,
|
||||
...query,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
|
||||
import { callByUser } from "./base"
|
||||
import { isArray, phpPost } from "./http"
|
||||
|
||||
// ==================== HTTP 充值记录 ====================
|
||||
// TODO: 接口路径待后端确认(旧后台为 php-api /http/* 充值列表接口)。
|
||||
|
||||
export async function fetchHttpRechargeList(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<HttpRechargeItem[]>> {
|
||||
return await callByUser<HttpRechargeItem[]>(
|
||||
"/api/course/v1/order/HttpRecharges",
|
||||
params,
|
||||
"GET",
|
||||
export async function fetchHttpRechargeList(): Promise<
|
||||
ApiResponse<HttpRechargeItem[]>
|
||||
> {
|
||||
const res = await phpPost<HttpRechargeItem[] | { data?: unknown }>(
|
||||
"/http/user/charge_list",
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
if (isArray<HttpRechargeItem>(res.data)) {
|
||||
return { success: true, data: res.data }
|
||||
}
|
||||
const list = isArray<HttpRechargeItem>(res.data?.data) ? res.data.data : []
|
||||
return { success: true, data: list }
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type {
|
||||
CreateOrderData,
|
||||
CreateOrderRequest,
|
||||
CreateTestAccountRequest,
|
||||
MyOrderPage,
|
||||
MyOrderListResult,
|
||||
MyOrderQueryParams,
|
||||
OrderItem,
|
||||
OrderQueryParams,
|
||||
@@ -53,15 +53,43 @@ export async function fetchOrderPage(
|
||||
)
|
||||
}
|
||||
|
||||
// 退款订单分页列表
|
||||
export async function fetchRefundOrders(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<OrderItem[]>> {
|
||||
return await callByUser<OrderItem[]>(
|
||||
"/api/course/v1/order/RefundOrders",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
// ==================== 个人中心订单/退货订单 ====================
|
||||
|
||||
// 响应结构:{ Code: 10000, data: [{ RowCount, List }] }
|
||||
async function fetchMyOrderPage(
|
||||
endpoint: string,
|
||||
params: MyOrderQueryParams,
|
||||
): Promise<ApiResponse<MyOrderListResult>> {
|
||||
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 = {
|
||||
OrderInfo: {
|
||||
OrderNo: string
|
||||
@@ -167,7 +158,7 @@ export async function createRechargeOrder(data: {
|
||||
}
|
||||
|
||||
// 轮询充值支付状态:返回 true 表示支付成功
|
||||
// 新后端 /user/IsPay 响应体:{ Code: 0|1, Message: "" },Code 本身即支付状态
|
||||
// 响应体:{ Code: 0|1, Message: "" },Code 本身即支付状态
|
||||
// 兼容旧格式 { Code: 10000, Data: 0|1 }
|
||||
export async function checkRechargeStatus(
|
||||
orderNo: string,
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function fetchWindowProducts(): Promise<
|
||||
)
|
||||
}
|
||||
|
||||
// 产品+套餐(用于账号管理筛选,接口来源:juipnet MyAccounts.cshtml)
|
||||
// 产品+套餐(用于账号管理筛选)
|
||||
export async function fetchProductWithPackage(): Promise<
|
||||
ApiResponse<ProductItem[]>
|
||||
> {
|
||||
|
||||
@@ -2,42 +2,50 @@ import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
HttpUsedLogItem,
|
||||
HttpUsedLogQuery,
|
||||
HttpUsedLogsResult,
|
||||
HttpUsedSummary,
|
||||
} from "@/lib/models/record"
|
||||
import { isArray, phpPost, toNumber, unwrapD } from "./http"
|
||||
import { isArray, parsePager, phpPost, toNumber, unwrapD } from "./http"
|
||||
|
||||
// ==================== HTTP 使用记录(php-api /http/user/*) ====================
|
||||
// TODO: 接口路径待后端确认,按现有 /http/user/* 命名规律推测。
|
||||
|
||||
// 包天包量使用概况
|
||||
export async function fetchHttpUsedSummary(): Promise<
|
||||
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,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const raw = res.data ?? {}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
todayShortPackIpUsed: toNumber(d.todayShortPackIpUsed),
|
||||
shortPackIpUsed: toNumber(d.shortPackIpUsed),
|
||||
todayCoinPackIpPaid:
|
||||
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(
|
||||
query: HttpUsedLogQuery,
|
||||
): Promise<ApiResponse<HttpUsedLogItem[]>> {
|
||||
const res = await phpPost("/http/user/pack_used_log", {
|
||||
): Promise<ApiResponse<HttpUsedLogsResult>> {
|
||||
const res = await phpPost<Record<string, unknown>>("/http/user/dx_used_log", {
|
||||
cookie: document.cookie,
|
||||
data: query,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const list = d.items ?? d.list ?? d.data
|
||||
const items = isArray<HttpUsedLogItem>(d.items) ? d.items : []
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<HttpUsedLogItem>(list) ? list : [],
|
||||
data: { items, pager: parsePager(d.pager, items.length) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
UserItem,
|
||||
UserQueryParams,
|
||||
} from "@/lib/models/user"
|
||||
import { callByUser, callPublic } from "./base"
|
||||
import { callByUser, callByUserCookie } from "./base"
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
@@ -54,11 +54,11 @@ export async function updateUserProfile(
|
||||
export async function updateUserInfo(
|
||||
data: UpdateUserInfoParams,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callPublic<undefined>("/user/ApiUpdateInfo", data)
|
||||
return await callByUserCookie<undefined>("/user/ApiUpdateInfo", data)
|
||||
}
|
||||
|
||||
export async function updateUserPwd(
|
||||
data: UpdateUserPwdParams,
|
||||
): Promise<ApiResponse<PhpUserModel>> {
|
||||
return await callPublic<PhpUserModel>("/user/UpdatePwd", data)
|
||||
return await callByUserCookie<PhpUserModel>("/user/UpdatePwd", data)
|
||||
}
|
||||
|
||||
@@ -1,56 +1,118 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { WhiteIpItem } from "@/lib/models/whitelist"
|
||||
import { isArray, phpPost, unwrapD } from "./http"
|
||||
import type { UnlimitedAvailable, WhiteIpItem } from "@/lib/models/whitelist"
|
||||
import { isArray, phpPost, toNumber, unwrapD } from "./http"
|
||||
|
||||
// ==================== 白名单(php-api /http/user/*) ====================
|
||||
// TODO: 后端接口路径待确认,以下路径按现有 /http/user/white_* 命名规律推测,
|
||||
// 后端就绪后需按实际路径修正。
|
||||
type RawHttpResponse = Record<string, unknown>
|
||||
|
||||
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,
|
||||
data,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const list = d.list ?? d.data ?? res.data?.data
|
||||
const raw = res.data ?? {}
|
||||
return {
|
||||
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>> {
|
||||
return await phpPost("/http/user/white_save", {
|
||||
cookie: document.cookie,
|
||||
ip,
|
||||
})
|
||||
// 白名单列表接口返回 data 直接为数组,兼容 { list: [...] } / { data: [...] }
|
||||
function parseList(raw: unknown): WhiteIpItem[] {
|
||||
const value = raw as RawHttpResponse | undefined
|
||||
const list = Array.isArray(raw) ? raw : (value?.list ?? value?.data)
|
||||
return isArray<WhiteIpItem>(list) ? list : []
|
||||
}
|
||||
|
||||
export async function deleteWhiteIp(
|
||||
id: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await phpPost("/http/user/white_delete", {
|
||||
// ==================== 白名单(普通) ====================
|
||||
|
||||
export async function fetchWhiteList(): Promise<ApiResponse<WhiteIpItem[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/white_list", {
|
||||
cookie: document.cookie,
|
||||
id,
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: parseList(res.data) }
|
||||
}
|
||||
|
||||
export async function lockWhiteIp(
|
||||
id: number,
|
||||
locked: boolean,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await phpPost("/http/user/white_lock", {
|
||||
cookie: document.cookie,
|
||||
id,
|
||||
locked: locked ? 1 : 0,
|
||||
})
|
||||
export function createWhiteIp(ip: string) {
|
||||
return postWhiteAction("/http/user/create_white_ip", { ip })
|
||||
}
|
||||
|
||||
export async function setWhiteLimitMax(
|
||||
limit: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await phpPost("/http/user/white_limit_set_su", {
|
||||
cookie: document.cookie,
|
||||
limit,
|
||||
})
|
||||
export function lockWhiteIp(id: number, lock: 0 | 1) {
|
||||
return postWhiteAction("/http/user/lock_ip", { id, lock })
|
||||
}
|
||||
|
||||
export function deleteWhiteIp(id: number) {
|
||||
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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useProfileStore } from "@/components/stores/profile"
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const isValid = useProfileStore(state => state.token !== null)
|
||||
const token = useProfileStore(state => state.token)
|
||||
const userInfo = useProfileStore(state => state.userInfo)
|
||||
|
||||
const isValid = token !== null && userInfo !== null
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
|
||||
@@ -20,10 +20,20 @@ export default function OrderFilterBar(props: {
|
||||
filters: OrderFilters
|
||||
products: ProductItem[]
|
||||
showOrderType?: boolean
|
||||
showPackageName?: boolean
|
||||
onPatch: (patch: Partial<OrderFilters>) => 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 (
|
||||
<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>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filters.packageName}
|
||||
onValueChange={value => onPatch({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{PACKAGE_OPTIONS.map(pkg => (
|
||||
<SelectItem key={pkg} value={pkg}>
|
||||
{pkg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showPackageName && (
|
||||
<Select
|
||||
value={filters.packageName}
|
||||
onValueChange={value => onPatch({ packageName: value })}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue placeholder="全部套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部套餐</SelectItem>
|
||||
{PACKAGE_OPTIONS.map(pkg => (
|
||||
<SelectItem key={pkg} value={pkg}>
|
||||
{pkg}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -115,6 +127,11 @@ export default function OrderFilterBar(props: {
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
查询
|
||||
</Button>
|
||||
{onReset && (
|
||||
<Button variant="outline" onClick={onReset}>
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ import { toast } from "sonner"
|
||||
import { useDashboardStore } from "@/components/stores/dashboard"
|
||||
import { useProfileStore } from "@/components/stores/profile"
|
||||
|
||||
// ==================== 前台购买/支付页面实名检查 ====================
|
||||
// ==================== 购买/支付页面实名检查 ====================
|
||||
// 已登录但未实名的用户访问购买/支付页面时:提示并跳转个人中心完成实名认证。
|
||||
// 未登录用户不拦截(下单时走登录流程)。参考 juipnet 老项目做法。
|
||||
// 未登录用户不拦截(下单时走登录流程)。
|
||||
export default function RealNameCheck() {
|
||||
const navigate = useNavigate()
|
||||
const token = useProfileStore(state => state.token)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -42,21 +40,6 @@ function Pagination({
|
||||
// 无数据时兜底为 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 变化
|
||||
useEffect(() => {
|
||||
setCurrentPage(page)
|
||||
@@ -64,13 +47,12 @@ function Pagination({
|
||||
|
||||
// 分页器逻辑
|
||||
const generatePaginationItems = () => {
|
||||
// 桌面端最多显示7个页码,移动端最多显示5个,其余用省略号
|
||||
const SIBLINGS = isMobile ? 0 : 1 // 当前页左右各显示的页码数
|
||||
const MAX_VISIBLE = isMobile ? 5 : 7
|
||||
// 最多显示7个页码,其余用省略号
|
||||
const SIBLINGS = 1 // 当前页左右各显示的页码数
|
||||
const DOTS = -1 // 省略号标记
|
||||
|
||||
if (totalPages <= MAX_VISIBLE) {
|
||||
// 总页数较少,全部显示
|
||||
if (totalPages <= 7) {
|
||||
// 总页数少于7,全部显示
|
||||
return Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||
}
|
||||
|
||||
@@ -139,13 +121,8 @@ function Pagination({
|
||||
const paginationItems = generatePaginationItems()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"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">
|
||||
<div className={cn("flex flex-wrap items-center gap-4", className)}>
|
||||
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
|
||||
共 {total} 条记录,每页
|
||||
<Select value={size.toString()} onValueChange={handlePageSizeChange}>
|
||||
<SelectTrigger className="h-8 w-20">
|
||||
@@ -168,7 +145,7 @@ function Pagination({
|
||||
<PaginationPrevious
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
className={
|
||||
currentPage <= 1 ? "opacity-50 pointer-events-none" : ""
|
||||
currentPage === 1 ? "opacity-50 pointer-events-none" : ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
@@ -198,7 +175,7 @@ function Pagination({
|
||||
<PaginationNext
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
className={
|
||||
currentPage >= totalPages
|
||||
currentPage === totalPages
|
||||
? "opacity-50 pointer-events-none"
|
||||
: ""
|
||||
}
|
||||
@@ -216,7 +193,6 @@ function PaginationLayout({
|
||||
}: React.ComponentProps<"nav">) {
|
||||
return (
|
||||
<nav
|
||||
// role="navigation"
|
||||
aria-label="pagination"
|
||||
data-slot="pagination"
|
||||
className={cn("flex-none", className)}
|
||||
@@ -232,10 +208,7 @@ function PaginationContent({
|
||||
return (
|
||||
<ul
|
||||
data-slot="pagination-content"
|
||||
className={cn(
|
||||
"flex flex-row flex-wrap items-center justify-center gap-1",
|
||||
className,
|
||||
)}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -260,7 +233,7 @@ function PaginationLink({
|
||||
data-slot="pagination-link"
|
||||
data-active={isActive}
|
||||
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`,
|
||||
isActive &&
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground",
|
||||
@@ -309,10 +282,7 @@ function PaginationEllipsis({
|
||||
<span
|
||||
aria-hidden
|
||||
data-slot="pagination-ellipsis"
|
||||
className={cn(
|
||||
"flex size-8 sm:size-9 items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
|
||||
@@ -141,6 +141,14 @@ export default function Common() {
|
||||
{/* 拖拽条*/}
|
||||
<div
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center justify-center py-2.5 cursor-pointer hover:bg-gray-50 transition-colors rounded-t-2xl"
|
||||
>
|
||||
<div className="w-10 h-1 bg-gray-300 rounded-full" />
|
||||
@@ -167,6 +175,14 @@ export default function Common() {
|
||||
{/* QQ */}
|
||||
<div
|
||||
onClick={handleQQClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleQQClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-blue-50 rounded-xl hover:bg-blue-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
@@ -185,6 +201,14 @@ export default function Common() {
|
||||
{/* 微信 */}
|
||||
<div
|
||||
onClick={handleWxClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
handleWxClick();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-green-50 rounded-xl hover:bg-green-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center">
|
||||
@@ -205,6 +229,14 @@ export default function Common() {
|
||||
onClick={() =>
|
||||
setActivePopup(activePopup === "gzh" ? null : "gzh")
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActivePopup(activePopup === "gzh" ? null : "gzh");
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-purple-50 rounded-xl hover:bg-purple-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500 flex items-center justify-center">
|
||||
@@ -248,6 +280,14 @@ export default function Common() {
|
||||
onClick={() =>
|
||||
setActivePopup(activePopup === "tel" ? null : "tel")
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setActivePopup(activePopup === "tel" ? null : "tel");
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex items-center gap-4 p-3 bg-orange-50 rounded-xl hover:bg-orange-100 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500 flex items-center justify-center">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { createOrder } from "@/api/order"
|
||||
import { createOrder, createTestAccount } from "@/api/order"
|
||||
import RealNameCheck from "@/components/realNameCheck"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -18,7 +18,6 @@ import Wrap from "@/components/wrap"
|
||||
import type { CreateOrderRequest } from "@/lib/models/order"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import { clearPayPaid, PAY_PAID_KEY, saveOrderPay } from "@/lib/recharge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import alipay from "../_assets/alipay.svg"
|
||||
import balance from "../_assets/balance.svg"
|
||||
import wechat from "../_assets/wechat.svg"
|
||||
@@ -41,42 +40,6 @@ function randomDigits(length: number): string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ============ 模式切换 Tab ============
|
||||
function ModeTabs({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: "single" | "batch"
|
||||
onModeChange: (mode: "single" | "batch") => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex bg-gray-50 rounded-xl p-1 w-48 max-md:w-full">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "single"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("single")}
|
||||
>
|
||||
单个注册
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "batch"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("batch")}
|
||||
>
|
||||
批量注册
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 连接设备数选择器 ============
|
||||
function ConnectCountSelector({
|
||||
value,
|
||||
@@ -176,50 +139,15 @@ function CouponSelector() {
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 批量注册说明 ============
|
||||
function BatchInfo() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28 md:pt-1">
|
||||
说明:
|
||||
</Label>
|
||||
<div className="flex-1 text-sm text-gray-600 leading-relaxed p-2 bg-blue-50/70 rounded-xl border border-blue-100">
|
||||
<p>
|
||||
批量注册的账号会使用【账号前缀】+【开始数】+【个数】顺序进行注册,
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
如:注册账号前缀为【user】开始数为【2】个数为【10】,则注册的账号为:
|
||||
<span className="text-blue-600 font-medium">
|
||||
{" "}
|
||||
user2,user3,user4,....user11
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 主页面 ============
|
||||
export default function BuyPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const product = location.state?.product as Product | undefined
|
||||
|
||||
// 模式
|
||||
const [mode, setMode] = useState<"single" | "batch">("single")
|
||||
|
||||
// 单个注册
|
||||
const [account, setAccount] = useState("")
|
||||
const [pwd, setPwd] = useState("")
|
||||
const [connectCount, setConnectCount] = useState(1)
|
||||
|
||||
// 批量注册
|
||||
const [batchAccount, setBatchAccount] = useState("")
|
||||
const [batchPwd, setBatchPwd] = useState("")
|
||||
const [batchStart, setBatchStart] = useState(1)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
const [batchConnectCount, setBatchConnectCount] = useState(1)
|
||||
|
||||
const [payType, setPayType] = useState("100")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -240,12 +168,8 @@ export default function BuyPage() {
|
||||
navigate("/product", { replace: true })
|
||||
return
|
||||
}
|
||||
const acct = randomChars(2) + randomDigits(4)
|
||||
const pw = randomDigits(3)
|
||||
setAccount(acct)
|
||||
setPwd(pw)
|
||||
setBatchAccount(randomChars(3))
|
||||
setBatchPwd(pw)
|
||||
setAccount(randomChars(2) + randomDigits(4))
|
||||
setPwd(randomDigits(3))
|
||||
}, [navigate, product])
|
||||
|
||||
if (!product) return null
|
||||
@@ -271,69 +195,63 @@ export default function BuyPage() {
|
||||
return total
|
||||
}
|
||||
|
||||
const singleTotal = clampMinPrice(
|
||||
const total = clampMinPrice(
|
||||
product.price * displayConnectCount,
|
||||
displayConnectCount,
|
||||
)
|
||||
const batchTotal = clampMinPrice(
|
||||
product.price * batchConnectCount * batchCount,
|
||||
batchConnectCount * batchCount,
|
||||
)
|
||||
const total = mode === "single" ? singleTotal : batchTotal
|
||||
|
||||
// 批量预览
|
||||
const batchPreview = (() => {
|
||||
if (!batchAccount) return ""
|
||||
const end = batchStart + batchCount - 1
|
||||
if (batchCount <= 4) {
|
||||
const items: string[] = []
|
||||
for (let i = batchStart; i <= end; i++) {
|
||||
items.push(`${batchAccount}${i}`)
|
||||
}
|
||||
return items.join(", ")
|
||||
}
|
||||
return `${batchAccount}${batchStart}, ${batchAccount}${batchStart + 1}, ${batchAccount}${batchStart + 2}...${batchAccount}${end}`
|
||||
})()
|
||||
|
||||
const handlePay = async () => {
|
||||
if (mode === "single") {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!batchAccount.trim() || !batchPwd.trim()) {
|
||||
toast.error("账号前缀和密码不能为空")
|
||||
return
|
||||
}
|
||||
if (batchCount > 500) {
|
||||
toast.error("一次最多注册500个")
|
||||
return
|
||||
}
|
||||
// 测试套餐:免费领取
|
||||
const handleClaim = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await createTestAccount({
|
||||
ProductId: product.productId,
|
||||
PackageId: packageId,
|
||||
Account: account.trim(),
|
||||
Pwd: pwd.trim(),
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success("领取成功")
|
||||
navigate("/admin/channels")
|
||||
} else {
|
||||
toast.error(result.message || "领取失败")
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 支付类型/通道映射:1余额 100支付宝 70微信;30微信 50支付宝 70余额
|
||||
const oPayType = Number(payType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
const useBalance = oPayType === 1
|
||||
const payChannel = oPayType === 1 ? 70 : oPayType === 70 ? 30 : 50
|
||||
|
||||
const params: CreateOrderRequest = {
|
||||
Account: account.trim(),
|
||||
PackageId: packageId,
|
||||
OrderType: mode === "single" ? 1 : 2,
|
||||
Account: mode === "single" ? account.trim() : batchAccount.trim(),
|
||||
Pwd: mode === "single" ? pwd.trim() : batchPwd.trim(),
|
||||
ConnectCount:
|
||||
mode === "single" ? displayConnectCount : batchConnectCount,
|
||||
Pwd: pwd.trim(),
|
||||
ConnectCount: displayConnectCount,
|
||||
CouponAmount: 0,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: product.price,
|
||||
...(mode === "batch" && {
|
||||
MinPostfix: batchStart,
|
||||
MaxPostfix: batchCount,
|
||||
}),
|
||||
UseAccountAmount: useBalance,
|
||||
}
|
||||
|
||||
const result = await createOrder(params)
|
||||
@@ -343,7 +261,6 @@ export default function BuyPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// 参考 juipnet Product/buy.cshtml:
|
||||
// Data == "00" 为余额支付直接成功;否则需要微信/支付宝支付
|
||||
const data = result.data
|
||||
if (typeof data === "string") {
|
||||
@@ -381,9 +298,6 @@ export default function BuyPage() {
|
||||
|
||||
const handleBack = () => navigate(-1)
|
||||
|
||||
// 天卡首单(0.1元)不显示批量注册;天卡>0.1元时显示(juipnet:应付款!=0.1时显示批量tab)
|
||||
const showTabs = !isTest && !isFirstOrderDayCard
|
||||
|
||||
return (
|
||||
<Wrap className="bg-white min-h-screen flex items-start justify-center pt-24">
|
||||
<RealNameCheck />
|
||||
@@ -392,249 +306,162 @@ export default function BuyPage() {
|
||||
<div className="text-center mb-6 space-y-2">
|
||||
<h4 className="text-2xl font-bold">
|
||||
<span className="text-gray-800">{product.title}</span>
|
||||
<span className="text-orange-500 ml-2">¥{total.toFixed(2)}</span>
|
||||
<span className="text-orange-500 ml-2">
|
||||
{isTest ? "免费领取" : `¥${total.toFixed(2)}`}
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-sm text-amber-600">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
{isDayCard && (
|
||||
<p className="text-sm text-amber-600">天卡不支持退款,请谨慎购买</p>
|
||||
{isTest ? (
|
||||
<p className="text-sm text-amber-600">
|
||||
测试套餐免费领取,领取成功后可在 IP账号管理 中查看
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-amber-600">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
{isDayCard && (
|
||||
<p className="text-sm text-amber-600">
|
||||
天卡不支持退款,请谨慎购买
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
{showTabs && (
|
||||
<div className="flex justify-center mb-3">
|
||||
<ModeTabs mode={mode} onModeChange={setMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5 mb-20">
|
||||
{mode === "single" ? (
|
||||
// ====== 单个注册 ======
|
||||
<>
|
||||
{/* IP产品账号 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品账号:
|
||||
</Label>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
4至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isTest && (
|
||||
<ConnectCountSelector
|
||||
value={displayConnectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
disabled={isFirstOrderDayCard}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// ====== 批量注册 ======
|
||||
<>
|
||||
{/* 批量说明 */}
|
||||
<BatchInfo />
|
||||
|
||||
{/* IP账号前缀 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP账号前缀:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchAccount}
|
||||
onChange={e => setBatchAccount(e.target.value)}
|
||||
placeholder="3-8位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
3至8位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 开始号 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
开始号:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchStart}
|
||||
onChange={e =>
|
||||
setBatchStart(Number.parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1 md:max-w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
本批次账号的起始账号
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 注册个数 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
注册个数:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchCount}
|
||||
onChange={e =>
|
||||
setBatchCount(
|
||||
Math.min(500, Number.parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1 md:max-w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账号预览 */}
|
||||
{batchPreview && (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
即将生成的账号:
|
||||
</Label>
|
||||
<span className="text-sm text-gray-700 font-medium">
|
||||
{batchPreview}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchPwd}
|
||||
onChange={e => setBatchPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 单账号连接设备数 */}
|
||||
<ConnectCountSelector
|
||||
value={batchConnectCount}
|
||||
onChange={setBatchConnectCount}
|
||||
label="单账号连接设备数"
|
||||
hint="每个账号可同时在线设备的数量"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 优惠券 */}
|
||||
{!isTest && <CouponSelector />}
|
||||
|
||||
{/* 余额 */}
|
||||
{/* IP产品账号 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
余额:
|
||||
IP产品账号:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 w-full md:w-auto md:flex-1">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">¥10.00</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
4至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
支付方式:
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={payType}
|
||||
onValueChange={setPayType}
|
||||
className="flex flex-wrap gap-x-8 gap-y-3 md:gap-8"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={balance} alt="余额" className="w-5 h-5" />
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 总金额 / 应付款 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
总金额:
|
||||
</Label>
|
||||
<span className="font-medium text-gray-800">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
应付款:
|
||||
</Label>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isTest && (
|
||||
<ConnectCountSelector
|
||||
value={displayConnectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
disabled={isFirstOrderDayCard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isTest && (
|
||||
<>
|
||||
{/* 优惠券 */}
|
||||
<CouponSelector />
|
||||
|
||||
{/* 余额 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
余额:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 w-full md:w-auto md:flex-1">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">
|
||||
¥10.00
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
支付方式:
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={payType}
|
||||
onValueChange={setPayType}
|
||||
className="flex flex-wrap gap-x-8 gap-y-3 md:gap-8"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={balance} alt="余额" className="w-5 h-5" />
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 总金额 / 应付款 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
总金额:
|
||||
</Label>
|
||||
<span className="font-medium text-gray-800">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
应付款:
|
||||
</Label>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
@@ -644,13 +471,23 @@ export default function BuyPage() {
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handlePay}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : "确认支付"}
|
||||
</Button>
|
||||
{isTest ? (
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handleClaim}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "领取中..." : "免费领取"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handlePay}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : "确认支付"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,16 +324,15 @@ export default function BuyStaticPage() {
|
||||
try {
|
||||
const oPayType = Number(payType)
|
||||
const params: CreateOrderRequest = {
|
||||
PackageId: packageId,
|
||||
OrderType: 1,
|
||||
Account: account.trim(),
|
||||
PackageId: packageId,
|
||||
Pwd: pwd.trim(),
|
||||
ConnectCount: 1,
|
||||
CouponAmount: 0,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: oPayType === 1 ? 1 : 0,
|
||||
UseAccountAmount: oPayType === 1,
|
||||
OPayType: oPayType,
|
||||
PayChannel: oPayType === 70 ? 30 : 50,
|
||||
Price: Number(getPrice()),
|
||||
PayChannel: oPayType === 1 ? 70 : oPayType === 70 ? 30 : 50,
|
||||
}
|
||||
|
||||
const result = await createOrder(params)
|
||||
@@ -353,7 +352,7 @@ export default function BuyStaticPage() {
|
||||
saveOrderPay({
|
||||
orderNo: data.OrderInfo.OrderNo,
|
||||
amount: Number(data.OrderInfo.OtherPayAmount) || Number(getPrice()),
|
||||
payChannel: oPayType === 70 ? 30 : 50,
|
||||
payChannel: oPayType === 1 ? 70 : oPayType === 70 ? 30 : 50,
|
||||
payData: data.PayData,
|
||||
})
|
||||
const win = window.open(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import {
|
||||
calcGamePrice,
|
||||
calcUnlimitedPrice,
|
||||
createHttpOrder,
|
||||
fetchGameCities,
|
||||
fetchGameLineCount,
|
||||
@@ -58,6 +59,9 @@ const DURATION_OPTIONS = [
|
||||
{ value: "360", label: "3小时~6小时" },
|
||||
]
|
||||
|
||||
// 短效无限量:有效时长固定 1~5 分钟
|
||||
const UNLIMITED_DURATION_OPTIONS = [{ value: "5", label: "1~5分钟" }]
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: "1", label: "按天" },
|
||||
{ value: "7", label: "按周" },
|
||||
@@ -65,6 +69,15 @@ const PERIOD_OPTIONS = [
|
||||
{ value: "90", label: "按季" },
|
||||
]
|
||||
|
||||
// 短效无限量:周期含按小时(1060),默认按小时
|
||||
const UNLIMITED_PERIOD_OPTIONS = [
|
||||
{ value: "1060", label: "按小时", unit: "小时" },
|
||||
{ value: "1", label: "按天", unit: "天" },
|
||||
{ value: "7", label: "按周", unit: "周" },
|
||||
{ value: "30", label: "按月", unit: "月" },
|
||||
{ value: "90", label: "按季", unit: "季" },
|
||||
]
|
||||
|
||||
const IP_AMOUNT_OPTIONS = [
|
||||
{ value: 5000, label: "每日最多使用 5,000 个IP" },
|
||||
{ value: 10000, label: "每日最多使用 10,000 个IP" },
|
||||
@@ -110,7 +123,7 @@ export default function HttpPage() {
|
||||
const [customAmount, setCustomAmount] = useState("")
|
||||
const [customIpAmount, setCustomIpAmount] = useState("")
|
||||
|
||||
// 未登录时跳转登录页,登录后回跳本页(参考 juipnet 产品页做法)
|
||||
// 未登录时跳转登录页,登录后回跳本页
|
||||
const ensureLogin = () => {
|
||||
if (token !== null) return true
|
||||
toast.error("请先登录")
|
||||
@@ -125,6 +138,15 @@ export default function HttpPage() {
|
||||
const [ipAmount, setIpAmount] = useState(5000)
|
||||
const [volumeAmount, setVolumeAmount] = useState(1)
|
||||
|
||||
// 短效无限量:周期类型/购买时长/白名单数量 + 服务端单价
|
||||
const [unlimitedPeriod, setUnlimitedPeriod] = useState(
|
||||
UNLIMITED_PERIOD_OPTIONS[0].value,
|
||||
)
|
||||
const [unlimitedPeriodCount, setUnlimitedPeriodCount] = useState(1)
|
||||
const [unlimitedUnitPrice, setUnlimitedUnitPrice] = useState(0)
|
||||
const [unlimitedPriceLoading, setUnlimitedPriceLoading] = useState(false)
|
||||
const unlimitedPriceSeqRef = useRef(0)
|
||||
|
||||
// 长效游戏
|
||||
const [game, _setGame] = useState<GameOrderData>(DEFAULT_GAME)
|
||||
const gameRef = useRef(game)
|
||||
@@ -153,6 +175,10 @@ export default function HttpPage() {
|
||||
const [gamePaying, setGamePaying] = useState(false)
|
||||
const alipayBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 短效无限量支付(H币)
|
||||
const [unlimitedPayOpen, setUnlimitedPayOpen] = useState(false)
|
||||
const [unlimitedPaying, setUnlimitedPaying] = useState(false)
|
||||
|
||||
const setGame = (next: GameOrderData) => {
|
||||
gameRef.current = next
|
||||
_setGame(next)
|
||||
@@ -340,6 +366,67 @@ export default function HttpPage() {
|
||||
setGamePaying(false)
|
||||
}
|
||||
|
||||
// 短效无限量:打开 H币 支付弹窗(与 juipnet 一致)
|
||||
const openUnlimitedPay = async () => {
|
||||
if (!ensureLogin()) return
|
||||
if (unlimitedTotal <= 0) {
|
||||
toast.error("价格获取失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
const res = await fetchHttpBalance()
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setBalance(res.data)
|
||||
setUnlimitedPayOpen(true)
|
||||
}
|
||||
|
||||
const payUnlimitedOrder = async () => {
|
||||
if (!ensureLogin()) return
|
||||
setUnlimitedPaying(true)
|
||||
const res = await createHttpOrder({
|
||||
order_type: 2,
|
||||
money: unlimitedTotal,
|
||||
pay_type: 1,
|
||||
data: {
|
||||
durationType: 5,
|
||||
periodType: Number(unlimitedPeriod),
|
||||
periodAmount: unlimitedPeriodCount,
|
||||
maxWhitelist: whitelistCount,
|
||||
},
|
||||
})
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
setUnlimitedPaying(false)
|
||||
return
|
||||
}
|
||||
const result = res.data
|
||||
if (result.code === 1) {
|
||||
setUnlimitedPayOpen(false)
|
||||
toast.success(result.msg || "支付成功")
|
||||
} else if (result.code === 2) {
|
||||
if (result.data && alipayBoxRef.current) {
|
||||
alipayBoxRef.current.innerHTML = result.data
|
||||
alipayBoxRef.current.querySelector("form")?.submit()
|
||||
setUnlimitedPayOpen(false)
|
||||
} else {
|
||||
toast.error(result.msg || "支付宝支付数据异常")
|
||||
}
|
||||
} else if (result.code === 3) {
|
||||
if (result.data) {
|
||||
setQrContent(result.data)
|
||||
setQrCodeOpen(true)
|
||||
setUnlimitedPayOpen(false)
|
||||
} else {
|
||||
toast.error(result.msg || "微信支付数据异常")
|
||||
}
|
||||
} else {
|
||||
toast.error(result.msg || "支付失败")
|
||||
}
|
||||
setUnlimitedPaying(false)
|
||||
}
|
||||
|
||||
const handleTabClick = (id: string) => {
|
||||
setActiveTab(id)
|
||||
if (id === "game") {
|
||||
@@ -352,6 +439,37 @@ export default function HttpPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 短效无限量:向服务端查询单价并计算总价(与 juipnet 一致)
|
||||
useEffect(() => {
|
||||
if (activeTab !== "unlimited") return
|
||||
const seq = ++unlimitedPriceSeqRef.current
|
||||
setUnlimitedPriceLoading(true)
|
||||
calcUnlimitedPrice({
|
||||
durationType: 5,
|
||||
periodType: Number(unlimitedPeriod),
|
||||
periodAmount: unlimitedPeriodCount,
|
||||
maxWhitelist: whitelistCount,
|
||||
}).then(res => {
|
||||
if (seq !== unlimitedPriceSeqRef.current) return
|
||||
setUnlimitedPriceLoading(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setUnlimitedUnitPrice(res.data)
|
||||
})
|
||||
}, [activeTab, unlimitedPeriod, unlimitedPeriodCount, whitelistCount])
|
||||
|
||||
// 短效无限量总价:periodAmount * whitelist * max(0.6, 1-0.1*(whitelist-1)) * 单价
|
||||
const unlimitedTotal = Number(
|
||||
(
|
||||
unlimitedPeriodCount *
|
||||
whitelistCount *
|
||||
Math.max(0.6, 1 - 0.1 * (whitelistCount - 1)) *
|
||||
unlimitedUnitPrice
|
||||
).toFixed(2),
|
||||
)
|
||||
|
||||
// 计算价格
|
||||
const calculatePrice = () => {
|
||||
// 根据不同的 tab 计算价格
|
||||
@@ -361,7 +479,7 @@ export default function HttpPage() {
|
||||
price = Number(customAmount) || 1
|
||||
break
|
||||
case "unlimited":
|
||||
price = whitelistCount * periodCount * 0.5
|
||||
price = unlimitedTotal
|
||||
break
|
||||
case "daily":
|
||||
price = ipAmount * periodCount * 0.01
|
||||
@@ -530,12 +648,8 @@ export default function HttpPage() {
|
||||
|
||||
<OptionBox label="有效时长">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{DURATION_OPTIONS.map(opt => (
|
||||
<OptionBtn
|
||||
key={opt.value}
|
||||
active={duration === opt.value}
|
||||
onClick={() => setDuration(opt.value)}
|
||||
>
|
||||
{UNLIMITED_DURATION_OPTIONS.map(opt => (
|
||||
<OptionBtn key={opt.value} active>
|
||||
{opt.label}
|
||||
</OptionBtn>
|
||||
))}
|
||||
@@ -544,11 +658,11 @@ export default function HttpPage() {
|
||||
|
||||
<OptionBox label="套餐周期">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PERIOD_OPTIONS.map(opt => (
|
||||
{UNLIMITED_PERIOD_OPTIONS.map(opt => (
|
||||
<OptionBtn
|
||||
key={opt.value}
|
||||
active={period === opt.value}
|
||||
onClick={() => setPeriod(opt.value)}
|
||||
active={unlimitedPeriod === opt.value}
|
||||
onClick={() => setUnlimitedPeriod(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionBtn>
|
||||
@@ -558,9 +672,13 @@ export default function HttpPage() {
|
||||
|
||||
<OptionBox label="购买时长">
|
||||
<Stepper
|
||||
value={periodCount}
|
||||
unit="小时"
|
||||
onChange={setPeriodCount}
|
||||
value={unlimitedPeriodCount}
|
||||
unit={
|
||||
UNLIMITED_PERIOD_OPTIONS.find(
|
||||
opt => opt.value === unlimitedPeriod,
|
||||
)?.unit ?? "小时"
|
||||
}
|
||||
onChange={setUnlimitedPeriodCount}
|
||||
/>
|
||||
</OptionBox>
|
||||
|
||||
@@ -572,6 +690,8 @@ export default function HttpPage() {
|
||||
onChange={v => setWhitelistCount(v)}
|
||||
min={1}
|
||||
max={1000}
|
||||
step={10}
|
||||
stepDown={1}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
您的白名单上限{whitelistCount}个,
|
||||
@@ -590,7 +710,11 @@ export default function HttpPage() {
|
||||
</div>
|
||||
</OptionBox>
|
||||
|
||||
<PriceBar price={totalPrice} onPay={openPayDialog} />
|
||||
<PriceBar
|
||||
price={unlimitedTotal}
|
||||
loading={unlimitedPriceLoading}
|
||||
onPay={openUnlimitedPay}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -717,6 +841,7 @@ export default function HttpPage() {
|
||||
placeholder="金额"
|
||||
className="w-28 h-7 inline-block text-center"
|
||||
/>
|
||||
{/* 这个金额需要根据选项自动更新和也支持手动显示 */}
|
||||
元
|
||||
</span>
|
||||
</div>
|
||||
@@ -1049,45 +1174,24 @@ export default function HttpPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* 长效游戏支付弹窗(H币支付) */}
|
||||
<Dialog open={gamePayOpen} onOpenChange={setGamePayOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
待支付:¥{gamePrice.toFixed(2)}元
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<HCoinPayDialog
|
||||
open={gamePayOpen}
|
||||
onOpenChange={setGamePayOpen}
|
||||
amount={gamePrice}
|
||||
balance={balance}
|
||||
paying={gamePaying}
|
||||
onPay={() => payGameOrder()}
|
||||
/>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-600">
|
||||
个人剩余H币:
|
||||
<span className="text-orange-500 font-medium ml-1">
|
||||
{balance.toFixed(2)}
|
||||
</span>
|
||||
<a
|
||||
href="/product/http"
|
||||
className="text-blue-500 hover:underline ml-2"
|
||||
>
|
||||
去充值
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
本次支付H币:{gamePrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => payGameOrder()}
|
||||
disabled={gamePaying}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 py-3"
|
||||
>
|
||||
{gamePaying ? "支付中..." : "立即支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 短效无限量支付弹窗(H币支付) */}
|
||||
<HCoinPayDialog
|
||||
open={unlimitedPayOpen}
|
||||
onOpenChange={setUnlimitedPayOpen}
|
||||
amount={unlimitedTotal}
|
||||
balance={balance}
|
||||
paying={unlimitedPaying}
|
||||
onPay={() => payUnlimitedOrder()}
|
||||
/>
|
||||
|
||||
{/* 支付宝表单渲染容器 */}
|
||||
<div ref={alipayBoxRef} className="hidden" />
|
||||
@@ -1120,6 +1224,64 @@ export default function HttpPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function HCoinPayDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
amount,
|
||||
balance,
|
||||
paying,
|
||||
onPay,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
amount: number
|
||||
balance: number
|
||||
paying: boolean
|
||||
onPay: () => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
待支付:¥{amount.toFixed(2)}元
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-600">
|
||||
个人剩余H币:
|
||||
<span className="text-orange-500 font-medium ml-1">
|
||||
{balance.toFixed(2)}
|
||||
</span>
|
||||
<a
|
||||
href="/product/http"
|
||||
className="text-blue-500 hover:underline ml-2"
|
||||
>
|
||||
去充值
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
本次支付H币:{amount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onPay}
|
||||
disabled={paying}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 py-3"
|
||||
>
|
||||
{paying ? "支付中..." : "立即支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function PrepaidCardCustom({
|
||||
amount,
|
||||
onAmountChange,
|
||||
@@ -1279,17 +1441,22 @@ function Stepper({
|
||||
onChange,
|
||||
min = 1,
|
||||
max,
|
||||
step = 1,
|
||||
stepDown,
|
||||
}: {
|
||||
value: number
|
||||
unit: string
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
stepDown?: number
|
||||
}) {
|
||||
const downStep = stepDown ?? step
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md border overflow-hidden">
|
||||
<button
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
onClick={() => onChange(Math.max(min, value - downStep))}
|
||||
disabled={value <= min}
|
||||
className="px-3 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
@@ -1300,7 +1467,9 @@ function Stepper({
|
||||
{unit}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onChange(max ? Math.min(max, value + 1) : value + 1)}
|
||||
onClick={() =>
|
||||
onChange(max ? Math.min(max, value + step) : value + step)
|
||||
}
|
||||
disabled={max !== undefined && value >= max}
|
||||
className="px-3 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||
onClick={() => handleBuy(product)}
|
||||
>
|
||||
立即购买
|
||||
{product.isTest ? "免费领取" : "立即购买"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,18 @@ export default function ProductTabs({
|
||||
activeId,
|
||||
onTabChange,
|
||||
}: ProductTabsProps) {
|
||||
|
||||
const visibleTabs = tabs.filter(tab => tab.id !== "window")
|
||||
|
||||
return (
|
||||
<Tabs value={activeId} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid w-full h-auto bg-transparent p-0 grid-cols-3">
|
||||
{tabs.map(tab => (
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-full h-auto bg-transparent p-0",
|
||||
visibleTabs.length === 2 ? "grid-cols-2" : "grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{visibleTabs.map(tab => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
|
||||
@@ -3,24 +3,29 @@ import { toast } from "sonner"
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
type MyOrderPage,
|
||||
type MyOrderListResult,
|
||||
type MyOrderQueryParams,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
} from "@/lib/models/order"
|
||||
import type { ListStatus } from "./useList"
|
||||
|
||||
const PAGE_SIZE = 10
|
||||
const DEFAULT_SIZE = 10
|
||||
|
||||
// 订单管理/退货订单共用的筛选+分页逻辑(.NET /user/ApiMyOrders、ApiMyRefundOrders)
|
||||
// 订单管理/退货订单共用
|
||||
// 接口返回全量列表({ RowCount, List }),本地做前端分页
|
||||
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 [data, setData] = useState<OrderItem[]>([])
|
||||
const [list, setList] = useState<OrderItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [status, setStatus] = useState<ListStatus>("load")
|
||||
const [page, setPage] = useState(1)
|
||||
const [size, setSize] = useState(DEFAULT_SIZE)
|
||||
|
||||
const patch = useCallback(
|
||||
(partial: Partial<OrderFilters>) =>
|
||||
@@ -28,28 +33,31 @@ export function useMyOrderPage(
|
||||
[],
|
||||
)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchPage({
|
||||
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,
|
||||
})
|
||||
if (res.success) {
|
||||
setData(res.data.List)
|
||||
setTotal(res.data.RowCount)
|
||||
setStatus("done")
|
||||
} else {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
}
|
||||
setPage(1)
|
||||
}, [fetchPage, filters])
|
||||
const fetchWith = useCallback(
|
||||
async (filterValues: OrderFilters) => {
|
||||
setStatus("load")
|
||||
const res = await fetchPage(mapFilters(filterValues))
|
||||
if (res.success) {
|
||||
setList(res.data.List)
|
||||
setTotal(
|
||||
res.data.RowCount > 0 ? res.data.RowCount : res.data.List.length,
|
||||
)
|
||||
setStatus("done")
|
||||
} else {
|
||||
setStatus("fail")
|
||||
toast.error(res.message)
|
||||
}
|
||||
setPage(1)
|
||||
},
|
||||
[fetchPage, mapFilters],
|
||||
)
|
||||
|
||||
const load = useCallback(() => fetchWith(filters), [fetchWith, filters])
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFilters(DEFAULT_FILTERS)
|
||||
fetchWith(DEFAULT_FILTERS)
|
||||
}, [fetchWith])
|
||||
|
||||
const loadedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
@@ -58,13 +66,28 @@ export function useMyOrderPage(
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
// 接口返回全量列表(含 RowCount),本地做前端分页
|
||||
const paged = useMemo(
|
||||
() => data.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
|
||||
[data, page],
|
||||
() => list.slice((page - 1) * size, page * size),
|
||||
[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,
|
||||
}
|
||||
}
|
||||
|
||||
77
src/lib/hooks/useOrderPage.ts
Normal file
77
src/lib/hooks/useOrderPage.ts
Normal 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 }
|
||||
}
|
||||
@@ -16,4 +16,5 @@ export type ScoreItem = {
|
||||
RestAmount2: string
|
||||
}
|
||||
|
||||
// 每页条数
|
||||
export const SCORE_PAGE_SIZE = 50
|
||||
|
||||
@@ -12,6 +12,13 @@ export type GameOrderData = {
|
||||
periodAmount: number
|
||||
}
|
||||
|
||||
export type DxwxlData = {
|
||||
durationType: number
|
||||
periodType: number
|
||||
periodAmount: number
|
||||
maxWhitelist: number
|
||||
}
|
||||
|
||||
export type GameCity = {
|
||||
code: number
|
||||
name: string
|
||||
@@ -27,7 +34,7 @@ export type HttpOrderInfo = {
|
||||
money: number
|
||||
pay_type: number
|
||||
// 预储值(order_type=1)下单时 data 为空对象
|
||||
data: GameOrderData | Record<string, never>
|
||||
data: GameOrderData | DxwxlData | Record<string, never>
|
||||
}
|
||||
|
||||
export type CreateOrderResult = {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
export type HttpRechargeItem = {
|
||||
create_time: string
|
||||
ju_money: number
|
||||
id: string
|
||||
user_id: string
|
||||
ju_money: string | number
|
||||
pay_money: string | number
|
||||
pay_type: string
|
||||
pay_money: number
|
||||
pay_status: string | number
|
||||
channel?: string | null
|
||||
order_no: string
|
||||
ali_wx_no: string
|
||||
create_time: string
|
||||
update_time: string
|
||||
}
|
||||
|
||||
export const HTTP_RECHARGE_PAY_STATUS_LABELS: Record<number, string> = {
|
||||
0: "未支付",
|
||||
1: "支付成功",
|
||||
}
|
||||
|
||||
export function formatPayStatus(value: string | number): string {
|
||||
return HTTP_RECHARGE_PAY_STATUS_LABELS[Number(value)] ?? String(value)
|
||||
}
|
||||
|
||||
@@ -1,37 +1,62 @@
|
||||
export type OrderItem = {
|
||||
TenantId: number
|
||||
UserId: number
|
||||
UserName: string
|
||||
Id: number
|
||||
OrderNo: string
|
||||
OrderType: number
|
||||
ProductId: number
|
||||
ProductName: string
|
||||
PackageId: number
|
||||
PackageName: string
|
||||
DayPrice: number
|
||||
CouponId: number
|
||||
CouponAmount: number
|
||||
OrderName: string
|
||||
OrderState: number
|
||||
PayType: number
|
||||
PayChannel: number
|
||||
PayState: number
|
||||
ConnectCount: number
|
||||
OrderAmount: number
|
||||
PaymentAmount: number
|
||||
AccountPayAmount: number
|
||||
OtherPayAmount: number
|
||||
DayPrice: number
|
||||
DayCount: number
|
||||
ClientType: number
|
||||
ChannelType: number
|
||||
AccountCount: number
|
||||
Accounts: string
|
||||
OrderAmount: number
|
||||
CouponAmount: number
|
||||
PaymentAmount: number
|
||||
AccountPwd: string
|
||||
RefundCount: number
|
||||
RefundAmount: number
|
||||
DayCount: number
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
BackAmount: number
|
||||
agent_id: number
|
||||
CreateTime: string
|
||||
UpdateTime: string
|
||||
DeleteTag: number
|
||||
}
|
||||
|
||||
// 产品购买(不含水滴)
|
||||
export type CreateOrderRequest = {
|
||||
PackageId: number
|
||||
OrderType: number
|
||||
Account: string
|
||||
PackageId: number
|
||||
Pwd: string
|
||||
ConnectCount: number
|
||||
MinPostfix?: number
|
||||
MaxPostfix?: number
|
||||
CouponId?: number
|
||||
UseAccountAmount?: number
|
||||
// 优惠券减免金额
|
||||
CouponAmount: number
|
||||
// 优惠券ID
|
||||
CouponId: number
|
||||
// 支付类型:1余额 100支付宝 70微信
|
||||
OPayType: number
|
||||
PayChannel?: number
|
||||
Price?: number
|
||||
// 支付通道:30微信 50支付宝 70余额
|
||||
PayChannel: number
|
||||
// 是否使用余额
|
||||
UseAccountAmount: boolean
|
||||
// 代理下用户会员号(可选)
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export type CreateTestAccountRequest = {
|
||||
@@ -69,6 +94,13 @@ export type OrderQueryParams = {
|
||||
PackageNames?: string[]
|
||||
}
|
||||
|
||||
export type OrderPageData = {
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
list: OrderItem[]
|
||||
}
|
||||
|
||||
export type MyOrderQueryParams = {
|
||||
BTime?: string
|
||||
ETime?: string
|
||||
@@ -78,7 +110,7 @@ export type MyOrderQueryParams = {
|
||||
KeyWord?: string
|
||||
}
|
||||
|
||||
export type MyOrderPage = {
|
||||
export type MyOrderListResult = {
|
||||
RowCount: number
|
||||
List: OrderItem[]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// 短效/长效套餐相关模型(与 php-api.juip.com 的 /http/user/* 接口对应)
|
||||
|
||||
// 短效套餐(已开通)
|
||||
export type DxPackage = {
|
||||
id: number
|
||||
@@ -11,6 +9,11 @@ export type DxPackage = {
|
||||
expireTime: string
|
||||
isExpired?: boolean | number | string
|
||||
ipAmount?: number
|
||||
userId?: number
|
||||
poolType?: number
|
||||
usedAmount?: number
|
||||
transferable?: number
|
||||
createTime?: number
|
||||
}
|
||||
|
||||
// 短效购买记录
|
||||
@@ -21,62 +24,98 @@ export type DxOrder = {
|
||||
durationType: string
|
||||
money: number | string
|
||||
create_time: string
|
||||
id?: string
|
||||
user_id?: string
|
||||
order_status?: number | string
|
||||
game_type?: string | null
|
||||
ip_count?: number | string | null
|
||||
order_id?: string
|
||||
payCash?: string
|
||||
serial?: string
|
||||
status?: number | string
|
||||
update_time?: string
|
||||
}
|
||||
|
||||
// 长效套餐(已开通)
|
||||
export type CxPackage = {
|
||||
id: number
|
||||
name?: string
|
||||
isAbroad: number | string
|
||||
isAbroad2?: number
|
||||
shareType: number | string
|
||||
ipAmount: number
|
||||
expireTime: string
|
||||
isExpired?: boolean | number | string
|
||||
expireTime: number | string
|
||||
transferable?: number
|
||||
isRelayed?: number
|
||||
}
|
||||
|
||||
// 长效购买记录
|
||||
export type CxOrder = {
|
||||
id?: string
|
||||
user_id?: string
|
||||
order_no: string
|
||||
order_type: number | string
|
||||
game_type: string
|
||||
ip_count: number
|
||||
package_type?: string
|
||||
durationType?: string | null
|
||||
order_status?: 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
|
||||
update_time?: string
|
||||
}
|
||||
|
||||
// 已开通长效 IP
|
||||
export type LangIpItem = {
|
||||
id: number
|
||||
packId?: number
|
||||
isRelayed: number
|
||||
isAbroad: number
|
||||
groupId?: number
|
||||
groupName?: string
|
||||
gameId?: number
|
||||
gameName?: string
|
||||
ip: string
|
||||
realIp: string
|
||||
index?: number
|
||||
httpPort: number
|
||||
socks5Port: number
|
||||
isp?: number
|
||||
cityCode?: number
|
||||
area: string
|
||||
bandwidth: number
|
||||
lineType: number | string
|
||||
lineType2?: number
|
||||
shareType: number | string
|
||||
shareType2?: number
|
||||
state: number | string
|
||||
username: string
|
||||
password: string
|
||||
expireTime: string
|
||||
isAbroad: number
|
||||
isRelayed: number
|
||||
expireTime: number | string
|
||||
description?: string
|
||||
bandwidth: number
|
||||
isExpired: boolean | number | string
|
||||
gameId?: number
|
||||
isp?: number
|
||||
state: number | string
|
||||
popState?: number | string
|
||||
popCityCode?: number
|
||||
popIsp?: number
|
||||
popArea?: string
|
||||
realIp: string
|
||||
}
|
||||
|
||||
// 长效 IP 切换记录
|
||||
export type SwitchRecord = {
|
||||
id?: number
|
||||
packId?: number
|
||||
oldIp: string
|
||||
oldArea: string
|
||||
newIp: string
|
||||
newArea: string
|
||||
reason: string
|
||||
createTime: string
|
||||
switchPrice?: number
|
||||
reason: number | string
|
||||
createTime: number | string
|
||||
}
|
||||
|
||||
// 服务端分页信息
|
||||
@@ -212,13 +251,34 @@ export const IP_STATE_LABELS: Record<number, string> = {
|
||||
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",也可能直接返回"已过期"/"未过期"字符串
|
||||
export function parseExpired(
|
||||
value: boolean | number | string | undefined,
|
||||
): boolean {
|
||||
return value === true || value === 1 || value === "1"
|
||||
return value === true || value === 1 || value === "1" || value === "已过期"
|
||||
}
|
||||
|
||||
export function formatExpired(
|
||||
@@ -236,6 +296,21 @@ export function formatIpState(value: number | string): string {
|
||||
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(
|
||||
labels: Record<number, string>,
|
||||
value: number | string,
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { Pager } from "./package"
|
||||
|
||||
export type HttpUsedSummary = {
|
||||
todayCoinPackIpPaid: string
|
||||
todayCoinPackIpUsed: number
|
||||
coinPackIpUsed: number
|
||||
todayShortPackIpUsed: number
|
||||
shortPackIpUsed: number
|
||||
}
|
||||
|
||||
export type HttpUsedLogItem = {
|
||||
id?: number
|
||||
packType: number
|
||||
userIp: string
|
||||
ip: string
|
||||
port: number
|
||||
createTime: string
|
||||
createTime: number | string
|
||||
}
|
||||
|
||||
export type HttpUsedLogQuery = {
|
||||
@@ -17,10 +23,16 @@ export type HttpUsedLogQuery = {
|
||||
packType: number
|
||||
}
|
||||
|
||||
export type HttpUsedLogsResult = {
|
||||
items: HttpUsedLogItem[]
|
||||
pager: Pager
|
||||
}
|
||||
|
||||
export const HTTP_USE_PACK_TYPE_LABELS: Record<number, string> = {
|
||||
12: "储值套餐",
|
||||
21: "短效包天套餐",
|
||||
22: "短效包量套餐",
|
||||
23: "短效无限量套餐",
|
||||
}
|
||||
|
||||
export const HTTP_USE_PACK_TYPE_OPTIONS = [21, 22]
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
export type WhiteIpItem = {
|
||||
id: number
|
||||
ip: string
|
||||
createTime: string
|
||||
description?: 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)
|
||||
}
|
||||
|
||||
@@ -69,3 +69,19 @@ export function formatDate(value: string): string {
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user