Compare commits

..

7 Commits

Author SHA1 Message Date
Eamon
1fc625ede8 产品购买 & 测试账号-免费领取 2026-10-13 13:32:53 +08:00
Eamon
160a9c9b97 取消单个窗口单IP选项 & 登录过期跳转到登录页面 2026-10-09 11:25:58 +08:00
Eamon
6151add53f 更新http管理二级路由页面 2026-09-22 14:47:50 +08:00
Eamon
eee72ad74a 更新http管理二级路由页面 2026-09-18 13:17:23 +08:00
Eamon
a902f5be29 完善充值记录和短效套餐页面 2026-09-15 17:39:51 +08:00
Eamon
aefbb8143e 个人中心的余额充值部分 2026-09-14 13:37:40 +08:00
Eamon
8aaf43ed79 补充上传协议和实名认证 2026-09-11 17:06:29 +08:00
63 changed files with 2734 additions and 1340 deletions

View File

@@ -1,10 +1,13 @@
import type { ColumnDef } from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { fetchCashOutList } from "@/api/cashOut"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { useList } from "@/lib/hooks/useList"
import type { CashOutItem } from "@/lib/models/cashOut"
const PAGE_SIZE = 10
const columns: ColumnDef<CashOutItem>[] = [
{
accessorKey: "username",
@@ -41,19 +44,32 @@ const columns: ColumnDef<CashOutItem>[] = [
export default function CashOutPage() {
const { data, status } = useList(fetchCashOutList)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(PAGE_SIZE)
// 接口返回全量列表,本地做前端分页
const paged = useMemo(
() => data.slice((page - 1) * pageSize, page * pageSize),
[data, page, pageSize],
)
return (
<Page>
<div className="bg-white rounded-lg p-4">
<h3 className="font-semibold text-lg">提现管理</h3>
<DataTable<CashOutItem>
data={data}
data={paged}
status={status}
columns={columns}
pagination={{
page: 0,
size: 10,
page,
size: pageSize,
total: data.length,
onPageChange: setPage,
onSizeChange: size => {
setPageSize(size)
setPage(1)
},
}}
/>
</div>

View File

@@ -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,21 +134,25 @@ export default function ChannelsPage() {
toast.error(res.message)
}
setPage(1)
setSelection({})
}, [])
const loadedRef = useRef(false)
useEffect(() => {
if (loadedRef.current) return
loadedRef.current = true
void load(toParams(INITIAL_QUERY))
load(toParams(INITIAL_QUERY))
}, [load])
const applyFilters = (patch: Partial<ChannelQuery>) => {
const next = { ...query, ...patch }
setQuery(next)
void load(toParams(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,8 +284,12 @@ export default function ChannelsPage() {
data={paged}
status={status}
columns={columns}
enableSelection
rowSelection={selection}
onSelectionChange={setSelection}
getRowId={row => String(row.Id)}
pagination={{
page: page - 1,
page,
size: PAGE_SIZE,
total: data.length,
onPageChange: setPage,

View File

@@ -52,12 +52,12 @@ function readUserInfo(userInfo: UserInfoRecord | null) {
const info = userInfo ?? {}
const phone = String(
info.Phone ??
info.LoginCode ??
info.phone ??
info.logincode ??
info.Mobile ??
info.mobile ??
"",
info.LoginCode ??
info.phone ??
info.logincode ??
info.Mobile ??
info.mobile ??
"",
)
const managerId = Number(info.ManagerId ?? info.managerId ?? 0)
return {
@@ -82,8 +82,8 @@ export default function DashboardPage() {
// 页面挂载时加载一次账户总览与协议签署状态
useEffect(() => {
void refresh()
void refreshAgreement()
refresh()
refreshAgreement()
}, [refresh, refreshAgreement])
// 支付窗口完成支付后(storage 事件)自动刷新余额
@@ -92,7 +92,7 @@ export default function DashboardPage() {
if (e.key !== PAY_PAID_KEY || !e.newValue) return
toast.success("充值成功,余额已更新")
clearPayPaid()
void refresh()
refresh()
}
window.addEventListener("storage", handler)
return () => window.removeEventListener("storage", handler)
@@ -407,7 +407,11 @@ export default function DashboardPage() {
onOpenChange={setRealNameOpen}
onVerified={refresh}
/>
<RechargeDialog open={rechargeOpen} onOpenChange={setRechargeOpen} />
<RechargeDialog
open={rechargeOpen}
onOpenChange={setRechargeOpen}
onPaid={refresh}
/>
<CashOutDialog
open={cashOutOpen}
onOpenChange={setCashOutOpen}

View File

@@ -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,34 +58,50 @@ 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(() => {
void load(1)
load(1)
}, [load])
const handlePageChange = (nextPage: number) => {
if (nextPage < 1) return
setPage(nextPage)
void load(nextPage)
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>
@@ -96,7 +112,7 @@ export default function FundsPage() {
status={status}
columns={columns}
pagination={{
page: page - 1,
page,
size: SCORE_PAGE_SIZE,
sizeOptions: [SCORE_PAGE_SIZE],
total,

View File

@@ -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>
)

View File

@@ -1,4 +1,5 @@
import { Outlet } from "react-router-dom"
import RealNameGate from "@/components/realNameGate"
import { Content, Header, Mask, Navbar, Shell } from "./clients"
import Effects from "./effects"
@@ -41,7 +42,9 @@ export default function AdminLayout() {
</Shell>
<Content>
<Outlet />
<RealNameGate>
<Outlet />
</RealNameGate>
</Content>
</div>
</>

View File

@@ -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={() => void load()}
onSearch={load}
onReset={reset}
/>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
@@ -90,10 +115,11 @@ export default function OrdersPage() {
status={status}
columns={columns}
pagination={{
page: page - 1,
size: MY_ORDER_PAGE_SIZE,
page,
size,
total,
onPageChange: setPage,
onSizeChange: changeSize,
}}
/>
</div>

View File

@@ -1,40 +0,0 @@
import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
export const MOCK_USED_SUMMARY: HttpUsedSummary = {
todayShortPackIpUsed: 3,
shortPackIpUsed: 45,
}
const PACK_IP_PREFIX = [
{ packType: 21, prefix: "211.90.13" },
{ packType: 22, prefix: "112.65.28" },
]
function pad(value: number): string {
return String(value).padStart(2, "0")
}
function buildLogs(count: number): HttpUsedLogItem[] {
const now = new Date()
const logs: HttpUsedLogItem[] = []
for (let i = 0; i < count; i += 1) {
const source = PACK_IP_PREFIX[i % PACK_IP_PREFIX.length]
const time = new Date(now.getTime() - i * 37 * 60 * 1000)
const createTime =
`${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ` +
`${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`
logs.push({
packType: source.packType,
userIp: `113.118.${(i % 200) + 10}.${(i % 250) + 3}`,
ip: `${source.prefix}.${(i % 240) + 10}`,
port: 6000 + (i % 300),
createTime,
})
}
return logs
}
export const MOCK_USED_LOGS = buildLogs(56)

View File

@@ -1,5 +1,7 @@
import type { ColumnDef } from "@tanstack/react-table"
import { 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)

View File

@@ -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={() => void load()}
onSearch={load}
onReset={reset}
/>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
@@ -85,10 +114,11 @@ export default function RefundOrdersPage() {
status={status}
columns={columns}
pagination={{
page: page - 1,
size: MY_ORDER_PAGE_SIZE,
page,
size,
total,
onPageChange: setPage,
onSizeChange: changeSize,
}}
/>
</div>

View File

@@ -97,7 +97,7 @@ export default function LongRenewDialog({
seqRef.current = seq
setPriceLoading(true)
void calcOrderPrice({
calcOrderPrice({
order_type: target.orderType,
money: 1,
data: target.data,
@@ -213,7 +213,7 @@ export default function LongRenewDialog({
<DialogFooter>
<Button
onClick={() => void handlePay()}
onClick={() => handlePay()}
disabled={paying || priceLoading}
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>

View File

@@ -49,7 +49,7 @@ export default function LongSwitchDialog({
setCities([])
seqRef.current += 1
void fetchSwitchCities(
fetchSwitchCities(
{
gameId: ip.gameId,
isAbroad: ip.isAbroad,
@@ -88,18 +88,16 @@ export default function LongSwitchDialog({
bandwidth: ip.bandwidth,
}
void calcOrderPrice({ order_type: 6, money: 1, data: rebuyData }).then(
res => {
if (seq !== seqRef.current) return
setPriceLoading(false)
if (!res.success) {
setMoney(0)
toast.error(res.message)
return
}
setMoney(Number(res.data.toFixed(2)))
},
)
calcOrderPrice({ order_type: 6, money: 1, data: rebuyData }).then(res => {
if (seq !== seqRef.current) return
setPriceLoading(false)
if (!res.success) {
setMoney(0)
toast.error(res.message)
return
}
setMoney(Number(res.data.toFixed(2)))
})
}, [open, ip])
const title = pop === 1 ? "切换入口" : "长效IP切换节点"
@@ -224,7 +222,7 @@ export default function LongSwitchDialog({
<DialogFooter>
<Button
onClick={() => void handlePay()}
onClick={() => handlePay()}
disabled={paying || priceLoading}
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>

View File

@@ -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"
@@ -148,25 +152,25 @@ export default function LongPage() {
}, [])
useEffect(() => {
void loadIps(1)
loadIps(1)
}, [loadIps])
const handleTabChange = (value: string) => {
setActiveTab(value)
if (value === "packages" && !packages.length) void loadPackages()
if (value === "orders" && !orders.length) void loadOrders()
if (value === "switch" && !switchList.length) void loadSwitch(1)
if (value === "packages" && !packages.length) loadPackages()
if (value === "orders" && !orders.length) loadOrders()
if (value === "switch" && !switchList.length) loadSwitch(1)
}
const handlePageChange = (page: number) => {
setIpPage(page)
setRowSelection({})
void loadIps(page)
loadIps(page)
}
const handleSwitchPageChange = (page: number) => {
setSwitchPage(page)
void loadSwitch(page)
loadSwitch(page)
}
const handleExtract = async () => {
@@ -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),
},
],
[],
)
@@ -358,7 +396,7 @@ export default function LongPage() {
<TabsContent value="ips" className="pt-4 flex flex-col gap-3">
<div className="flex items-center gap-4 flex-wrap">
<Button
onClick={() => void handleExtract()}
onClick={() => handleExtract()}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
批量提取IP
@@ -376,7 +414,7 @@ export default function LongPage() {
onSelectionChange={setRowSelection}
getRowId={row => String(row.id)}
pagination={{
page: ipPage - 1,
page: ipPage,
size: PAGE_SIZE,
total: ipTotal,
sizeOptions: [PAGE_SIZE],
@@ -407,7 +445,7 @@ export default function LongPage() {
status={switchStatus}
columns={switchColumns}
pagination={{
page: switchPage - 1,
page: switchPage,
size: PAGE_SIZE,
total: switchTotal,
sizeOptions: [PAGE_SIZE],
@@ -423,8 +461,8 @@ export default function LongPage() {
source={renewSource}
onClose={() => setRenewSource(null)}
onSuccess={() => {
void loadIps(ipPage)
void loadPackages()
loadIps(ipPage)
loadPackages()
}}
/>
@@ -433,7 +471,7 @@ export default function LongPage() {
ip={switchTarget?.ip ?? null}
pop={switchTarget?.pop ?? 0}
onClose={() => setSwitchTarget(null)}
onSuccess={() => void loadIps(ipPage)}
onSuccess={() => loadIps(ipPage)}
/>
</Page>
)

View File

@@ -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
@@ -126,7 +279,7 @@ export default function ShortApiLink({ packages }: { packages: DxPackage[] }) {
const [link, setLink] = useState("")
useEffect(() => {
void fetchUserApiToken().then(res => {
fetchUserApiToken().then(res => {
if (res.success) setToken(res.data)
})
}, [])
@@ -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)}
@@ -470,7 +625,7 @@ export default function ShortApiLink({ packages }: { packages: DxPackage[] }) {
<div className="flex-1 border border-gray-200 rounded-md px-3 py-2 text-sm text-gray-600 truncate bg-gray-50">
{link || "点击“生成API链接”后此处显示链接"}
</div>
<Button onClick={() => void handleCopy()} variant="outline">
<Button onClick={() => handleCopy()} variant="outline">
复制链接
</Button>
<Button onClick={handleOpen} variant="outline">
@@ -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>
)
}

View File

@@ -59,7 +59,7 @@ export default function ShortRenewDialog({
const orderType = orderTypeOf(pkg)
setPriceLoading(true)
void calcOrderPrice({
calcOrderPrice({
order_type: orderType,
money: 1,
data: {
@@ -200,7 +200,7 @@ export default function ShortRenewDialog({
<DialogFooter>
<Button
onClick={() => void handlePay()}
onClick={() => handlePay()}
disabled={paying || priceLoading}
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>

View File

@@ -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
@@ -53,15 +55,15 @@ export default function ShortPage() {
}, [])
useEffect(() => {
void loadPackages()
void fetchWhiteLimitMax().then(res => {
loadPackages()
fetchWhiteLimitMax().then(res => {
if (res.success && res.data > 0) setWhitelist(res.data)
})
}, [loadPackages])
const handleTabChange = (value: string) => {
setActiveTab(value)
if (value === "orders" && !orders.length) void loadOrders()
if (value === "orders" && !orders.length) loadOrders()
}
const packageColumns = useMemo<ColumnDef<DxPackage>[]>(
@@ -173,7 +175,7 @@ export default function ShortPage() {
pkg={renewPackage}
whitelist={whitelist}
onClose={() => setRenewPackage(null)}
onSuccess={() => void loadPackages()}
onSuccess={() => loadPackages()}
/>
</Page>
)

View File

@@ -1,4 +1,5 @@
import type { ColumnDef } from "@tanstack/react-table"
import { useMemo, useState } from "react"
import { fetchRosOrderList } from "@/api/rosorder"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
@@ -48,19 +49,31 @@ const columns: ColumnDef<RosOrderItem>[] = [
export default function RosorderPage() {
const { data, status } = useList(fetchRosOrderList)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const paged = useMemo(
() => data.slice((page - 1) * pageSize, page * pageSize),
[data, page, pageSize],
)
return (
<Page>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
<h3 className="font-semibold text-lg">软路由订单列表</h3>
<DataTable
data={data}
data={paged}
status={status}
columns={columns}
pagination={{
page: 0,
size: 10,
page,
size: pageSize,
total: data.length,
onPageChange: setPage,
onSizeChange: size => {
setPageSize(size)
setPage(1)
},
}}
/>
</div>

View File

@@ -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" />
删除

View File

@@ -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>

View File

@@ -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",

View File

@@ -1,13 +1,11 @@
import type { ApiResponse } from "@/lib/api"
import { phpForm, phpGet, phpPost } from "./http"
// ==================== 实名认证 / 网络安全协议(php-api) ====================
// 接口来源:参考 juipnet 老项目 Host/Views/User/Index.cshtml、upload.cshtml
export type RealNameVerifyResult = {
// 10000:进入微信扫码扫脸阶段(返回二维码 url)
// 20000:已认证成功
// 30000:该身份已被其他账号认证
// -10000:接口问题,联系客服
// 其他:姓名与身份证不匹配
code: number
url?: string
@@ -75,8 +73,14 @@ export async function uploadAgreement(props: {
form,
)
if (!res.success) return res
// 后端约定:code 1 成功,-1 失败(msg 携带失败原因,如文件过大/已上传过)
const code = Number(res.data?.code ?? 0)
const msg = typeof res.data?.msg === "string" ? res.data.msg : undefined
if (code === -1) {
return { success: false, message: msg || "上传失败" }
}
return {
success: true,
data: { msg: typeof res.data?.msg === "string" ? res.data.msg : undefined },
data: { msg },
}
}

View File

@@ -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})`
: "请求失败"),
}
}
@@ -93,6 +130,7 @@ async function request<R = undefined>(
return {
success: false,
status: response.status,
code: json.Code,
message: json.Message || "请求失败",
}
}
@@ -100,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 {
@@ -202,6 +246,7 @@ async function call<R = undefined>(
if (mode === "user") {
const token = getUserToken()
if (!token) {
handleUnauthorized()
return {
success: false,
status: 401,
@@ -209,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
}
@@ -228,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
}
@@ -277,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 }

View File

@@ -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",

View File

@@ -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 : [],
}
}

View File

@@ -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)

View File

@@ -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 }
}

View File

@@ -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,6 +158,8 @@ export async function createRechargeOrder(data: {
}
// 轮询充值支付状态:返回 true 表示支付成功
// 响应体:{ Code: 0|1, Message: "" },Code 本身即支付状态
// 兼容旧格式 { Code: 10000, Data: 0|1 }
export async function checkRechargeStatus(
orderNo: string,
): Promise<ApiResponse<boolean>> {
@@ -175,6 +168,8 @@ export async function checkRechargeStatus(
undefined,
"GET",
)
if (!res.success) return res
return { success: true, data: res.data === 1 }
if (res.success) return { success: true, data: res.data === 1 }
if (res.code === 1) return { success: true, data: true }
if (res.code === 0) return { success: true, data: false }
return res
}

View File

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

View File

@@ -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) },
}
}

View File

@@ -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)
}

View File

@@ -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),
},
}
}

View File

@@ -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 (

View File

@@ -51,6 +51,10 @@ export default function AgreementDialog({
toast.error("手持身份证必须上传!")
return
}
if (file.size > 30 * 1024 * 1024) {
toast.error("文件过大,仅限30M")
return
}
setSubmitting(true)
const res = await uploadAgreement({ describe: describe.trim(), file })
@@ -203,7 +207,7 @@ export default function AgreementDialog({
<DialogFooter>
<Button
onClick={() => void handleSubmit()}
onClick={() => handleSubmit()}
disabled={submitting}
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>

View File

@@ -14,7 +14,6 @@ import { Label } from "@/components/ui/label"
const ALIPAY_ACCOUNT_RE =
/^(?:1[3-9]\d{9}|[a-zA-Z\d._-]*@[a-zA-Z\d.-]{1,10}\.[a-zA-Z\d]{1,20})$/
const CUSTOMER_SERVICE_URL =
"https://admin.qidian.qq.com/template/blue/mp/menu/qr-code-jump.html?linkType=0&env=ol&kfuin=2852138148&fid=299&key=f377ec024ca45115a03a7632c7bda230&cate=1&source=&isLBS=&isCustomEntry=&type=16&ftype=1&_type=wpa&qidian=true"
@@ -198,7 +197,7 @@ export function CashOutDialog({
<Button
type="button"
disabled={submitting}
onClick={() => void handleSubmit()}
onClick={() => handleSubmit()}
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
>
{submitting ? "提交中..." : "确认提现"}

View File

@@ -31,10 +31,13 @@ export default function RealNameDialog({
open,
onOpenChange,
onVerified,
mandatory = false,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onVerified: () => void
/** 强制模式:不可关闭,直到完成实名认证(由外层 Gate 在认证成功后卸载) */
mandatory?: boolean
}) {
const [stage, setStage] = useState<"form" | "scan">("form")
const [qrUrl, setQrUrl] = useState("")
@@ -46,6 +49,7 @@ export default function RealNameDialog({
})
const handleOpenChange = (next: boolean) => {
if (!next && mandatory) return
onOpenChange(next)
if (!next) {
form.reset({ name: "", idCode: "" })
@@ -56,7 +60,7 @@ export default function RealNameDialog({
const handleFinish = () => {
onVerified()
onOpenChange(false)
if (!mandatory) onOpenChange(false)
}
const onSubmit = async (data: RealNameValues) => {
@@ -80,7 +84,10 @@ export default function RealNameDialog({
}
if (code === 30000) {
toast.error("该身份已经进行认证,无法再次进行认证,请联系在线客服处理")
handleFinish()
return
}
if (code === -10000) {
toast.error("接口问题,请联系客服")
return
}
if (code === 10000 && url) {
@@ -93,7 +100,12 @@ export default function RealNameDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-lg">
<DialogContent
className="max-h-[85dvh] overflow-y-auto sm:max-w-lg"
showCloseButton={!mandatory}
onEscapeKeyDown={mandatory ? e => e.preventDefault() : undefined}
onPointerDownOutside={mandatory ? e => e.preventDefault() : undefined}
>
{stage === "form" ? (
<>
<DialogHeader>
@@ -159,13 +171,15 @@ export default function RealNameDialog({
</div>
<DialogFooter className="gap-3">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
取消
</Button>
{!mandatory && (
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
>
取消
</Button>
)}
<Button
type="submit"
disabled={submitting}
@@ -195,9 +209,14 @@ export default function RealNameDialog({
</div>
<DialogFooter className="gap-3">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
取消
</Button>
{!mandatory && (
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
>
取消
</Button>
)}
<Button
onClick={handleFinish}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"

View File

@@ -1,5 +1,12 @@
import { useCallback, useEffect, useState } from "react"
import { CheckCircle2, Loader2, XCircle } from "lucide-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import {
checkRechargeStatus,
createRechargeOrder,
type RechargeOrderResult,
} from "@/api/order"
import { QrCodeImage } from "@/components/qrCodeImage"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -15,37 +22,258 @@ import wechat from "@/home/_assets/wechat.svg"
type PayChannel = "50" | "30"
const POLL_INTERVAL = 3000
interface RechargeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
// 支付成功后回调(刷新余额等)
onPaid?: () => void
}
export function RechargeDialog({ open, onOpenChange }: RechargeDialogProps) {
// 余额充值(与官网 juipnet User/Index.cshtml 一致):
// - 下单 POST /user/CreateOrder
// - 支付宝(50):PayData 为自动提交表单,渲染后自动跳转支付宝网关
// - 微信(30):PayData 为扫码链接,直接渲染二维码,轮询 /user/IsPay
export function RechargeDialog({
open,
onOpenChange,
onPaid,
}: RechargeDialogProps) {
const [amount, setAmount] = useState("100")
const [payChannel, setPayChannel] = useState<PayChannel>("50")
const [order, setOrder] = useState<RechargeOrderResult | null>(null)
const [creating, setCreating] = useState(false)
const [orderError, setOrderError] = useState("")
const [paid, setPaid] = useState(false)
const [failCount, setFailCount] = useState(0)
const alipayBoxRef = useRef<HTMLDivElement>(null)
const alipaySubmittedRef = useRef(false)
const reset = useCallback(() => {
setAmount("100")
setPayChannel("50")
setOrder(null)
setCreating(false)
setOrderError("")
setPaid(false)
setFailCount(0)
alipaySubmittedRef.current = false
}, [])
useEffect(() => {
if (!open) reset()
}, [open, reset])
const handleGoPay = () => {
const handleGoPay = async () => {
const chargeAmount = Number(amount)
if (!Number.isFinite(chargeAmount) || chargeAmount <= 0) {
toast.error("请输入正确的充值金额")
return
}
window.open(
`/pay?amount=${chargeAmount}&channel=${payChannel}`,
"_blank",
"noopener,noreferrer",
setCreating(true)
setOrderError("")
const res = await createRechargeOrder({
ChargeAmount: chargeAmount,
PayChannel: Number(payChannel),
})
setCreating(false)
if (!res.success) {
setOrderError(res.message)
return
}
if (!res.data?.PayData) {
setOrderError("下单失败,请稍后重试")
return
}
setOrder(res.data)
}
// 支付宝:渲染 PayData 表单并自动提交,跳转支付宝网关
useEffect(() => {
if (!open || payChannel !== "50" || !order || orderError) return
if (alipaySubmittedRef.current) return
alipaySubmittedRef.current = true
const box = alipayBoxRef.current
if (!box) return
box.innerHTML = order.PayData
box.querySelector("form")?.submit()
}, [open, payChannel, order, orderError])
// 微信:轮询 /user/IsPay 直到支付成功
useEffect(() => {
if (!open || payChannel !== "30" || !order || paid) return
let stopped = false
const poll = async () => {
const res = await checkRechargeStatus(order.OrderInfo.OrderNo)
if (stopped) return
if (res.success && res.data) {
setPaid(true)
toast.success("充值成功,余额已更新")
onPaid?.()
} else if (!res.success) {
setFailCount(count => count + 1)
}
}
poll()
const timer = window.setInterval(poll, POLL_INTERVAL)
return () => {
stopped = true
window.clearInterval(timer)
}
}, [open, payChannel, order, paid, onPaid])
const isWechat = payChannel === "30"
const renderBody = () => {
if (creating) {
return (
<div className="flex flex-col items-center gap-2 py-10">
<Loader2 className="size-8 animate-spin text-blue-500" />
<p className="text-sm text-gray-500">正在生成订单...</p>
</div>
)
}
if (orderError) {
return (
<div className="flex flex-col items-center gap-3 py-6">
<XCircle className="size-10 text-red-400" />
<p className="text-sm text-red-500">{orderError}</p>
<Button variant="outline" className="w-full" onClick={reset}>
重新发起
</Button>
</div>
)
}
if (order && !isWechat) {
return (
<div className="flex flex-col items-center gap-2 py-10">
<Loader2 className="size-8 animate-spin text-blue-500" />
<p className="text-sm text-gray-500">正在跳转支付宝支付...</p>
<p className="text-xs text-gray-400">
如未自动跳转,请检查浏览器是否拦截
</p>
</div>
)
}
if (order && isWechat) {
if (paid) {
return (
<div className="flex flex-col items-center gap-3 py-6">
<CheckCircle2 className="size-12 text-emerald-500" />
<p className="font-medium text-emerald-600">支付成功</p>
<p className="text-xs text-gray-500">余额已更新</p>
<Button className="w-full" onClick={() => onOpenChange(false)}>
完成
</Button>
</div>
)
}
return (
<div className="space-y-4 py-1">
<div className="flex items-center justify-center gap-2">
<img src={wechat} alt="微信支付" className="size-6" />
<span className="font-semibold">微信支付 | 收银台</span>
</div>
<div className="space-y-1 rounded-lg bg-gray-50 p-4 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">应付金额</span>
<span className="font-bold text-orange-500">
¥{Number(order.OrderInfo.PaymentAmount ?? amount).toFixed(2)}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">收款方</span>
<span>聚IP</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">订单号</span>
<span className="font-mono text-xs leading-5">
{order.OrderInfo.OrderNo}
</span>
</div>
</div>
<div className="flex flex-col items-center gap-3">
<div className="rounded-lg border border-gray-200 p-2">
<QrCodeImage text={order.PayData} size={200} />
</div>
<p className="text-xs text-gray-500">
请使用微信扫码支付,支付完成后自动确认
</p>
<div className="flex items-center gap-1.5 text-xs text-gray-400">
<Loader2 className="size-3 animate-spin" />
正在等待支付结果
{failCount > 2 && "(网络波动,请稍候)"}
</div>
</div>
</div>
)
}
return (
<div className="space-y-4 mt-1">
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">充值金额</Label>
<Input
type="number"
min={1}
value={amount}
onChange={e => setAmount(e.target.value)}
className="h-11 bg-gray-50 border-gray-200"
placeholder="请输入充值金额"
/>
</div>
<div className="space-y-2">
<Label className="text-sm text-gray-600">支付方式</Label>
<RadioGroup
value={payChannel}
onValueChange={value => setPayChannel(value as PayChannel)}
className="flex gap-6"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="50" id="recharge-alipay" />
<Label
htmlFor="recharge-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="30" id="recharge-wechat" />
<Label
htmlFor="recharge-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>
</RadioGroup>
</div>
<Button
type="button"
onClick={handleGoPay}
disabled={creating}
className="w-full h-11 bg-blue-600 hover:bg-blue-700 text-white font-medium"
>
充值
</Button>
</div>
)
onOpenChange(false)
}
return (
@@ -55,57 +283,9 @@ export function RechargeDialog({ open, onOpenChange }: RechargeDialogProps) {
<DialogTitle className="text-center">账户充值</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-1">
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">充值金额</Label>
<Input
type="number"
min={1}
value={amount}
onChange={e => setAmount(e.target.value)}
className="h-11 bg-gray-50 border-gray-200"
placeholder="请输入充值金额"
/>
</div>
{renderBody()}
<div className="space-y-2">
<Label className="text-sm text-gray-600">支付方式</Label>
<RadioGroup
value={payChannel}
onValueChange={value => setPayChannel(value as PayChannel)}
className="flex gap-6"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="50" id="recharge-alipay" />
<Label
htmlFor="recharge-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="30" id="recharge-wechat" />
<Label
htmlFor="recharge-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>
</RadioGroup>
</div>
<Button
type="button"
onClick={handleGoPay}
className="w-full h-11 bg-blue-600 hover:bg-blue-700 text-white font-medium"
>
充值
</Button>
</div>
<div ref={alipayBoxRef} className="hidden" />
</DialogContent>
</Dialog>
)

View File

@@ -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>
)

View File

@@ -95,7 +95,8 @@ export default function DataTable<T extends Record<string, unknown>>(
},
state: {
pagination: {
pageIndex: props.pagination?.page ?? 0,
// Pagination 组件页码为 1 起始,tanstack 内部为 0 起始
pageIndex: (props.pagination?.page ?? 1) - 1,
pageSize: props.pagination?.size ?? props.data.length,
},
columnFilters: [],

View File

@@ -0,0 +1,31 @@
import { useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { useDashboardStore } from "@/components/stores/dashboard"
import { useProfileStore } from "@/components/stores/profile"
// ==================== 购买/支付页面实名检查 ====================
// 已登录但未实名的用户访问购买/支付页面时:提示并跳转个人中心完成实名认证。
// 未登录用户不拦截(下单时走登录流程)。
export default function RealNameCheck() {
const navigate = useNavigate()
const token = useProfileStore(state => state.token)
const verified = useDashboardStore(state => state.verified)
const data = useDashboardStore(state => state.data)
const refresh = useDashboardStore(state => state.refresh)
// 无缓存时先拉取实名状态
useEffect(() => {
if (token && !data) refresh()
}, [token, data, refresh])
useEffect(() => {
if (!token || verified !== false) return
toast.error(
"接主管部门要求,会员需要实名认证方可使用该产品,请先前往个人中心完成实名认证",
)
navigate("/admin", { replace: true })
}, [token, verified, navigate])
return null
}

View File

@@ -0,0 +1,77 @@
import { Loader2 } from "lucide-react"
import { type ReactNode, useEffect } from "react"
import RealNameDialog from "@/components/composites/dialogs/realNameDialog"
import { useDashboardStore } from "@/components/stores/dashboard"
import { Button } from "@/components/ui/button"
// ==================== 个人中心实名强制拦截 ====================
// 未实名用户访问 /admin 任意页面时:屏蔽页面内容,强制弹出实名认证弹窗(不可关闭),
// 认证成功后自动刷新状态并恢复页面展示。参考 juipnet 老项目做法。
export default function RealNameGate({ children }: { children: ReactNode }) {
const verified = useDashboardStore(state => state.verified)
const data = useDashboardStore(state => state.data)
const loading = useDashboardStore(state => state.loading)
const error = useDashboardStore(state => state.error)
const refresh = useDashboardStore(state => state.refresh)
// 无缓存数据时先拉取一次账户信息以获取实名状态
useEffect(() => {
if (!data) refresh()
}, [data, refresh])
if (verified === true) {
return <>{children}</>
}
if (verified === false) {
return (
<>
<div className="flex h-full items-center justify-center p-6">
<div className="w-full max-w-md rounded-xl bg-card p-6 text-center shadow-sm ring-1 ring-gray-100">
<h3 className="text-lg font-semibold text-blue-600">
尊敬的用户,您还未进行实名认证!
</h3>
<p className="mt-3 text-sm leading-6 text-gray-600">
接主管部门要求,会员需要实名认证后方可访问个人中心及使用相关服务。
请您在下方弹窗中完成实名认证,认证通过后将自动为您打开页面。
</p>
<p className="mt-2 text-xs leading-5 text-gray-400">
您的个人信息仅与国家公安一所信息库比对,本公司不调用及储存您的实人认证数据。
</p>
</div>
</div>
<RealNameDialog
open
onOpenChange={() => {}}
onVerified={refresh}
mandatory
/>
</>
)
}
// verified === null:状态未知(加载中或加载失败)
if (loading || (!data && !error)) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 text-gray-400">
<Loader2 className="size-8 animate-spin text-blue-500" />
<p className="text-sm">正在加载账户信息...</p>
</div>
)
}
return (
<div className="flex h-full items-center justify-center p-6">
<div className="w-full max-w-md rounded-xl bg-card p-6 text-center shadow-sm ring-1 ring-gray-100">
<h3 className="text-lg font-semibold">账户信息加载失败</h3>
<p className="mt-2 text-sm text-gray-500">{error || "请稍后重试"}</p>
<Button
className="mt-4 bg-blue-600 text-white hover:bg-blue-700"
onClick={refresh}
>
重新加载
</Button>
</div>
</div>
)
}

View File

@@ -9,6 +9,7 @@ import type { ApiIndexItem } from "@/lib/models/dashboard"
export type DashboardState = {
data: ApiIndexItem | null
agreementSigned: boolean | null
verified: boolean | null
loading: boolean
error: string | null
}
@@ -23,13 +24,19 @@ export type DashboardStore = DashboardState & DashboardActions
export const useDashboardStore = create<DashboardStore>()(set => ({
data: null,
agreementSigned: null,
verified: null,
loading: false,
error: null,
refresh: async () => {
set({ loading: true })
const res = await fetchApiIndex()
if (res.success) {
set({ data: res.data, error: null, loading: false })
set({
data: res.data,
verified: res.data ? res.data.UserModel?.is_verify === 1 : null,
error: null,
loading: false,
})
} else {
set({ error: res.message, loading: false })
}

View File

@@ -1,5 +1,3 @@
"use client"
import {
ChevronLeftIcon,
ChevronRightIcon,
@@ -19,6 +17,7 @@ import {
} from "./select"
export interface PaginationProps {
/** 当前页码,1 起始;onPageChange 回调参数同样为 1 起始页码 */
page: number
size: number
total: number
@@ -38,22 +37,8 @@ function Pagination({
className,
}: PaginationProps) {
const [currentPage, setCurrentPage] = useState(page)
const totalPages = Math.ceil(total / size)
// 移动端检测:小屏幕减少页码按钮数量,避免溢出
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)
}, [])
// 无数据时兜底为 1 页,避免除零与空分页器
const totalPages = size > 0 ? Math.max(Math.ceil(total / size), 1) : 1
// 同步外部 page 变化
useEffect(() => {
@@ -62,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)
}
@@ -137,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">
@@ -214,7 +193,6 @@ function PaginationLayout({
}: React.ComponentProps<"nav">) {
return (
<nav
// role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("flex-none", className)}
@@ -230,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}
/>
)
@@ -258,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",
@@ -307,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" />

View File

@@ -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">

View File

@@ -48,7 +48,7 @@ export default function HelpPage() {
}
setArticles(transformArticles(res.data.List ?? []))
}
void load()
load()
return () => {
cancelled = true
}

View File

@@ -59,20 +59,22 @@ export default function LoginPage() {
<Button
variant="ghost"
onClick={() => setTab("login")}
className={`flex-1 py-2 rounded-md transition-all ${tab === "login"
className={`flex-1 py-2 rounded-md transition-all ${
tab === "login"
? "bg-white shadow-sm text-blue-600 font-medium"
: "text-gray-600 hover:text-gray-900"
}`}
}`}
>
登录
</Button>
<Button
variant="ghost"
onClick={() => setTab("register")}
className={`flex-1 py-2 rounded-md transition-all ${tab === "register"
className={`flex-1 py-2 rounded-md transition-all ${
tab === "register"
? "bg-white shadow-sm text-blue-600 font-medium"
: "text-gray-600 hover:text-gray-900"
}`}
}`}
>
注册
</Button>
@@ -90,9 +92,17 @@ export default function LoginPage() {
function LoginForm() {
const navigate = useNavigate()
const location = useLocation()
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false)
// 登录成功后回跳来源页(仅接受站内路径)
const rawRedirect = new URLSearchParams(location.search).get("redirect")
const redirect =
rawRedirect?.startsWith("/") && !rawRedirect.startsWith("//")
? rawRedirect
: "/"
const form = useForm<LoginValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
@@ -114,7 +124,7 @@ function LoginForm() {
toast.success("登录成功", {
description: "欢迎回来!",
})
navigate("/")
navigate(redirect, { replace: true })
} else {
toast.error(resp.message || "登录失败")
}

View File

@@ -1,13 +1,8 @@
import { CheckCircle2, Loader2, XCircle } from "lucide-react"
import { useCallback, useEffect, useState } from "react"
import { useSearchParams } from "react-router-dom"
import {
checkPayStatus,
checkRechargeStatus,
createRechargeOrder,
type RechargeOrderResult,
} from "@/api/order"
import { checkPayStatus } from "@/api/order"
import { QrCodeImage } from "@/components/qrCodeImage"
import RealNameCheck from "@/components/realNameCheck"
import { Button } from "@/components/ui/button"
import { clearOrderPay, notifyPayPaid, readOrderPay } from "@/lib/recharge"
import alipay from "../_assets/alipay.svg"
@@ -15,10 +10,10 @@ import wechat from "../_assets/wechat.svg"
const POLL_INTERVAL = 3000
// 收银台(独立窗口):
// - 充值模式 /pay?amount=100&channel=30:页面内下单 /user/CreateOrder,轮询 /user/IsPay
// - 购买模式 /pay?mode=order:主页面已下单,读取 localStorage 支付数据,轮询 /product/IsPay
// 参考 juipnet Host/Views/User/Index.cshtml(charge)与 Product/buy.cshtml(payCallback/isPay)
// 购买收银台(独立窗口):
// 主页面下单成功后把支付数据写入 localStorage,本页读取并轮询 /product/IsPay
// 参考 juipnet Host/Views/Product/buy.cshtml(payCallback/isPay)
// (余额充值已改为个人中心页面内弹层,见 rechargeDialog.tsx)
type PollState = "paid" | "pending" | "error"
@@ -44,8 +39,8 @@ function usePayPolling(
}
}
void poll()
const timer = window.setInterval(() => void poll(), POLL_INTERVAL)
poll()
const timer = window.setInterval(() => poll(), POLL_INTERVAL)
return () => {
stopped = true
window.clearInterval(timer)
@@ -56,124 +51,11 @@ function usePayPolling(
}
export default function PayPage() {
const [searchParams] = useSearchParams()
const mode = searchParams.get("mode")
return mode === "order" ? <OrderPay /> : <RechargePay />
}
// ==================== 充值模式 ====================
function RechargePay() {
const [searchParams] = useSearchParams()
const amountParam = Number(searchParams.get("amount"))
const channelParam = searchParams.get("channel")
const valid =
Number.isFinite(amountParam) &&
amountParam > 0 &&
(channelParam === "50" || channelParam === "30")
const [order, setOrder] = useState<RechargeOrderResult | null>(null)
const [orderError, setOrderError] = useState("")
const [autoOpened, setAutoOpened] = useState(false)
useEffect(() => {
if (!valid) return
let cancelled = false
createRechargeOrder({
ChargeAmount: amountParam,
PayChannel: Number(channelParam),
}).then(res => {
if (cancelled) return
if (res.success) {
setOrder(res.data)
} else {
setOrderError(res.message)
}
})
return () => {
cancelled = true
}
}, [valid, amountParam, channelParam])
useEffect(() => {
if (!order?.PayData || autoOpened) return
// 支付宝:PayData 是支付链接或自动提交表单,直接跳转;
// 微信:交给 CashierLayout 判断(链接/表单跳转,weixin:// 扫码内容显示二维码)
if (channelParam !== "50") return
setAutoOpened(true)
const payData = order.PayData
if (/^https?:\/\//i.test(payData)) {
window.location.href = payData
} else {
document.open()
document.write(payData)
document.close()
}
}, [order, channelParam, autoOpened])
const checkPaid = useCallback(async (orderNo: string): Promise<PollState> => {
const res = await checkRechargeStatus(orderNo)
if (!res.success) return "error"
return res.data ? "paid" : "pending"
}, [])
const { paid, failCount } = usePayPolling(order?.OrderInfo.OrderNo, checkPaid)
const isAlipay = channelParam === "50"
if (isAlipay) {
return (
<div className="min-h-dvh flex items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-sm rounded-xl bg-white p-6 shadow-sm text-center">
{orderError ? (
<>
<XCircle className="mx-auto size-10 text-red-400" />
<p className="mt-2 text-sm text-red-500">{orderError}</p>
<p className="text-xs text-gray-500">请回到原页面重新发起</p>
</>
) : !order ? (
<>
<Loader2 className="mx-auto size-8 animate-spin text-blue-500" />
<p className="mt-2 text-sm text-gray-500">正在生成订单...</p>
</>
) : paid ? (
<>
<CheckCircle2 className="mx-auto size-12 text-emerald-500" />
<p className="mt-2 font-medium text-emerald-600">支付成功</p>
<p className="text-xs text-gray-500">
订单已生效,可以关闭本页面
</p>
</>
) : (
<>
<Loader2 className="mx-auto size-8 animate-spin text-blue-500" />
<p className="mt-2 text-sm text-gray-500">正在跳转支付宝...</p>
<p className="text-xs text-gray-500">
如未自动跳转,请检查浏览器是否拦截
</p>
</>
)}
</div>
</div>
)
}
return (
<CashierLayout
isWechat={true}
amount={amountParam}
orderNo={order?.OrderInfo.OrderNo}
payData={order?.PayData ?? ""}
paid={paid}
loading={valid && !order && !orderError}
error={orderError}
failCount={failCount}
invalid={!valid}
/>
<>
<RealNameCheck />
<OrderPay />
</>
)
}
// ==================== 购买模式 ====================

View File

@@ -1,7 +1,8 @@
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"
import { Label } from "@/components/ui/label"
@@ -17,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"
@@ -40,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,
@@ -175,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)
@@ -239,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
@@ -270,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)
@@ -342,7 +261,6 @@ export default function BuyPage() {
return
}
// 参考 juipnet Product/buy.cshtml:
// Data == "00" 为余额支付直接成功;否则需要微信/支付宝支付
const data = result.data
if (typeof data === "string") {
@@ -380,259 +298,170 @@ 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 />
<div className="w-full max-w-2xl px-0 md:px-8">
{/* 标题 */}
<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
@@ -642,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>

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { createOrder } from "@/api/order"
import RealNameCheck from "@/components/realNameCheck"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -323,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)
@@ -352,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(
@@ -388,6 +388,7 @@ export default function BuyStaticPage() {
const [regionSearch, setRegionSearch] = useState("")
return (
<Wrap className="bg-white min-h-screen flex items-start justify-center pt-24">
<RealNameCheck />
<div className="w-full max-w-2xl px-0 md:px-8">
{/* 标题 */}
<div className="text-center mb-3">
@@ -616,7 +617,7 @@ export default function BuyStaticPage() {
</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={() => void handlePay()}
onClick={() => handlePay()}
disabled={submitting}
>
{submitting ? "提交中..." : "确认支付"}

View File

@@ -1,13 +1,18 @@
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,
fetchGameList,
fetchHttpBalance,
} from "@/api/http"
import { QrCodeImage } from "@/components/qrCodeImage"
import RealNameCheck from "@/components/realNameCheck"
import { useProfileStore } from "@/components/stores/profile"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
@@ -54,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: "按周" },
@@ -61,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" },
@@ -100,10 +117,20 @@ const DEFAULT_GAME: GameOrderData = {
}
export default function HttpPage() {
const navigate = useNavigate()
const token = useProfileStore(state => state.token)
const [activeTab, setActiveTab] = useState("prepaid")
const [customAmount, setCustomAmount] = useState("")
const [customIpAmount, setCustomIpAmount] = useState("")
// 未登录时跳转登录页,登录后回跳本页
const ensureLogin = () => {
if (token !== null) return true
toast.error("请先登录")
navigate(`/login?redirect=${encodeURIComponent("/product/http")}`)
return false
}
const [duration, setDuration] = useState(DURATION_OPTIONS[0].value)
const [period, setPeriod] = useState(PERIOD_OPTIONS[0].value)
const [periodCount, setPeriodCount] = useState(1)
@@ -111,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)
@@ -129,12 +165,20 @@ export default function HttpPage() {
const isRecharge = true
const [isLoading, setIsLoading] = useState(false)
// 微信扫码支付弹窗
const [qrCodeOpen, setQrCodeOpen] = useState(false)
const [qrContent, setQrContent] = useState("")
// 长效游戏支付(H币)
const [gamePayOpen, setGamePayOpen] = useState(false)
const [balance, setBalance] = useState(0)
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)
@@ -218,11 +262,11 @@ export default function HttpPage() {
const seq = gameSeqRef.current + 1
gameSeqRef.current = seq
setGame(next)
void loadGamePrice(next, seq)
loadGamePrice(next, seq)
}
const selectAbroad = (isAbroad: number) => {
void applyGame(
applyGame(
{
...gameRef.current,
isAbroad,
@@ -236,34 +280,35 @@ export default function HttpPage() {
const selectShareType = (shareType: number) => {
if (shareType === 1) {
void applyGame({
applyGame({
...gameRef.current,
shareType,
lineType: 1,
gameId: 0,
})
} else {
void applyGame({ ...gameRef.current, shareType }, { fetchGames: true })
applyGame({ ...gameRef.current, shareType }, { fetchGames: true })
}
}
const changeRelay = (isRelayed: number) => {
void applyGame({ ...gameRef.current, isRelayed })
applyGame({ ...gameRef.current, isRelayed })
}
const changeIsp = (isp: number) => {
void applyGame({ ...gameRef.current, isp })
applyGame({ ...gameRef.current, isp })
}
const changeCity = (cityCode: number) => {
void applyGame({ ...gameRef.current, cityCode })
applyGame({ ...gameRef.current, cityCode })
}
const changeGameId = (gameId: number) => {
void applyGame({ ...gameRef.current, gameId })
applyGame({ ...gameRef.current, gameId })
}
const openGamePay = async () => {
if (!ensureLogin()) return
if (lineCountRef.current <= 0) {
toast.error("可用IP为0!")
return
@@ -282,6 +327,7 @@ export default function HttpPage() {
}
const payGameOrder = async () => {
if (!ensureLogin()) return
setGamePaying(true)
const res = await createHttpOrder({
order_type: 5,
@@ -306,16 +352,85 @@ export default function HttpPage() {
} else {
toast.error(result.msg || "支付宝支付数据异常")
}
} else if (result.code === 3) {
if (result.data) {
setQrContent(result.data)
setQrCodeOpen(true)
setGamePayOpen(false)
} else {
toast.error(result.msg || "微信支付数据异常")
}
} else {
toast.error(result.msg || "支付失败")
}
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") {
void applyGame(
applyGame(
{ ...gameRef.current, periodType: 1 },
{
fetchCities: true,
@@ -324,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 计算价格
@@ -333,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
@@ -350,18 +496,52 @@ export default function HttpPage() {
const totalPrice = calculatePrice()
// 处理支付
const handlePay = () => {
const handlePay = async () => {
if (!ensureLogin()) return
setIsLoading(true)
// 模拟支付请求
setTimeout(() => {
setIsLoading(false)
const res = await createHttpOrder({
order_type: 1,
money: payAmount,
pay_type: Number(payMethod),
data: {},
})
setIsLoading(false)
if (!res.success) {
toast.error(res.message)
return
}
const result = res.data
if (result.code === 1) {
setPayDialogOpen(false)
toast.success(`支付成功!支付金额:¥${payAmount.toFixed(2)}`)
}, 1500)
toast.success(result.msg || "充值成功")
} else if (result.code === 2) {
if (result.data && alipayBoxRef.current) {
alipayBoxRef.current.innerHTML = result.data
alipayBoxRef.current.querySelector("form")?.submit()
setPayDialogOpen(false)
} else {
toast.error(result.msg || "支付宝支付数据异常")
}
} else if (result.code === 3) {
if (result.data) {
setQrContent(result.data)
setQrCodeOpen(true)
setPayDialogOpen(false)
} else {
toast.error(result.msg || "微信支付数据异常")
}
} else {
toast.error(result.msg || "支付失败")
}
}
// 打开支付弹窗
// 打开支付弹窗(仅预储值 tab 支持支付宝/微信在线充值)
const openPayDialog = (amount?: number) => {
if (!ensureLogin()) return
if (activeTab !== "prepaid") {
toast.error("该套餐暂不支持在线支付,请联系客服购买")
return
}
const effectivePrice = Number(amount ?? totalPrice)
if (effectivePrice <= 0) {
toast.error("请输入有效的充值金额")
@@ -374,6 +554,7 @@ export default function HttpPage() {
return (
<>
<Toaster position="top-center" richColors />
<RealNameCheck />
<Wrap className="bg-white">
<section className="py-20">
<div className="text-center mb-6">
@@ -467,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>
))}
@@ -481,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>
@@ -495,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>
@@ -509,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}个,
@@ -527,7 +710,11 @@ export default function HttpPage() {
</div>
</OptionBox>
<PriceBar price={totalPrice} onPay={openPayDialog} />
<PriceBar
price={unlimitedTotal}
loading={unlimitedPriceLoading}
onPay={openUnlimitedPay}
/>
</div>
)}
@@ -654,6 +841,7 @@ export default function HttpPage() {
placeholder="金额"
className="w-28 h-7 inline-block text-center"
/>
{/* 这个金额需要根据选项自动更新和也支持手动显示 */}
元
</span>
</div>
@@ -908,7 +1096,7 @@ export default function HttpPage() {
<PriceBar
price={gamePrice}
loading={gamePriceLoading}
onPay={() => void openGamePay()}
onPay={() => openGamePay()}
/>
</div>
)}
@@ -986,53 +1174,114 @@ 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={() => void 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" />
{/* 微信扫码支付弹窗 */}
<Dialog open={qrCodeOpen} onOpenChange={setQrCodeOpen}>
<DialogContent className="sm:max-w-xs">
<DialogHeader>
<DialogTitle className="text-center">微信扫码支付</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 pb-2">
<div className="rounded-lg border border-gray-200 p-2">
<QrCodeImage text={qrContent} size={220} />
</div>
<p className="text-xs text-muted-foreground">
请使用微信扫码支付
</p>
<Button
variant="outline"
className="w-full"
onClick={() => setQrCodeOpen(false)}
>
已支付完成
</Button>
</div>
</DialogContent>
</Dialog>
</Wrap>
</>
)
}
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,
@@ -1175,9 +1424,9 @@ function OptionBtn({
className={cn(
"rounded-md text-sm font-medium transition-all",
active &&
"bg-linear-to-r from-blue-500 to-cyan-400 text-white border-transparent shadow-sm hover:opacity-90",
"bg-linear-to-r from-blue-500 to-cyan-400 text-white border-transparent shadow-sm hover:opacity-90",
!active &&
"border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-500",
"border-gray-200 bg-white text-gray-600 hover:border-blue-300 hover:text-blue-500",
className,
)}
>
@@ -1192,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"
>
@@ -1213,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"
>

View File

@@ -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>

View File

@@ -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}
@@ -33,4 +41,4 @@ export default function ProductTabs({
</TabsList>
</Tabs>
)
}
}

View File

@@ -1,4 +1,5 @@
import { useState } from "react"
import RealNameCheck from "@/components/realNameCheck"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
@@ -120,6 +121,7 @@ export default function RouterosPage() {
return (
<Wrap className="flex flex-col gap-10 pt-30 pb-16 bg-white">
<RealNameCheck />
<div className="flex flex-col lg:flex-row gap-10">
<div className="lg:w-5/12">
<Card className="overflow-hidden border-0 shadow-md">

View File

@@ -13,6 +13,8 @@ type ApiResponse<T = undefined> =
success: false
status?: number
message: string
// 后端业务码(非 10000 时透传),如 /user/IsPay 的 Code 0/1 即支付状态
code?: number
}
| {
success: true

View File

@@ -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,43 +33,61 @@ 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(() => {
if (loadedRef.current) return
loadedRef.current = true
void load()
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,
}
}

View File

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

View File

@@ -16,4 +16,5 @@ export type ScoreItem = {
RestAmount2: string
}
// 每页条数
export const SCORE_PAGE_SIZE = 50

View File

@@ -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
@@ -26,7 +33,8 @@ export type HttpOrderInfo = {
order_type: number
money: number
pay_type: number
data: GameOrderData
// 预储值(order_type=1)下单时 data 为空对象
data: GameOrderData | DxwxlData | Record<string, never>
}
export type CreateOrderResult = {

View File

@@ -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)
}

View File

@@ -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[]
}

View File

@@ -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,

View File

@@ -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]

View File

@@ -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)
}

View File

@@ -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}`
}