补充个人中心下的页面
This commit is contained in:
48
src/admin/cashOut/page.tsx
Normal file
48
src/admin/cashOut/page.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import type { CashOutItem } from "@/lib/models/cashOut"
|
||||
|
||||
|
||||
export default function CashOutPage() {
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg">提现管理</h3>
|
||||
<DataTable<CashOutItem>
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "用户",
|
||||
},
|
||||
{
|
||||
accessorKey: "money",
|
||||
header: "提现金额",
|
||||
},
|
||||
{
|
||||
accessorKey: "reason",
|
||||
header: "提现理由",
|
||||
},
|
||||
{
|
||||
accessorKey: "alipay_account",
|
||||
header: "支付宝账号",
|
||||
},
|
||||
{
|
||||
accessorKey: "remark",
|
||||
header: "备注",
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "状态",
|
||||
},
|
||||
{
|
||||
accessorKey: "create_time",
|
||||
header: "提现时间",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
53
src/admin/channels/mock.ts
Normal file
53
src/admin/channels/mock.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { ChannelRecord } from "@/lib/models/channel"
|
||||
|
||||
// TODO: 后端通道列表接口就绪后删除(见 src/api/channel.ts fetchChannelPage)
|
||||
export const INITIAL_CHANNELS: ChannelRecord[] = [
|
||||
{
|
||||
id: "1",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "甘肃-兰州125.75.111.127",
|
||||
server: "p3.125.74.129.38.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "已过期",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "辽宁-辽阳123.190.161.192",
|
||||
server: "p16.cqdwx4.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "静态-项目-20M-月卡",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "黑龙江-鸡西100.100.4.249",
|
||||
server: "p37.sdlw2.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
]
|
||||
@@ -10,6 +10,9 @@ import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import type { ChannelRecord } from "@/lib/models/channel"
|
||||
import { INITIAL_CHANNELS } from "./mock"
|
||||
|
||||
// TODO: 后端就绪后替换为 fetchChannelPage(见 src/api/channel.ts)
|
||||
|
||||
const filterSchema = z.object({
|
||||
expired_at_end: z.string().optional(),
|
||||
@@ -21,61 +24,8 @@ const filterSchema = z.object({
|
||||
|
||||
type FormValues = z.infer<typeof filterSchema>
|
||||
|
||||
const initialData: ChannelRecord[] = [
|
||||
{
|
||||
id: "1",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "甘肃-兰州125.75.111.127",
|
||||
server: "p3.125.74.129.38.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "已过期",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "测试卡(带宽)",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "辽宁-辽阳123.190.161.192",
|
||||
server: "p16.cqdwx4.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
startAt: "2025.04.09",
|
||||
product: "极光IP",
|
||||
plan: "静态-项目-20M-月卡",
|
||||
account: "ft8765",
|
||||
password: "765修改",
|
||||
ip: "黑龙江-鸡西100.100.4.249",
|
||||
server: "p37.sdlw2.sdip.cc",
|
||||
connections: 1,
|
||||
expiredAt: "2025.04.09",
|
||||
remaining: "0天00时37分",
|
||||
online: "查看",
|
||||
download: "下载",
|
||||
status: "",
|
||||
},
|
||||
]
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const [filterValues, setFilterValues] = useState<FormValues>({})
|
||||
const [status, setStatus] = useState<"load" | "done">("done")
|
||||
// 行选择状态
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||
@@ -91,10 +41,10 @@ export default function ChannelsPage() {
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!filterValues || Object.values(filterValues).every(value => !value)) {
|
||||
return initialData
|
||||
return INITIAL_CHANNELS
|
||||
}
|
||||
|
||||
return initialData.filter(record => {
|
||||
return INITIAL_CHANNELS.filter(record => {
|
||||
if (filterValues.expired_at_end) {
|
||||
return record.expiredAt <= filterValues.expired_at_end
|
||||
}
|
||||
@@ -126,12 +76,8 @@ export default function ChannelsPage() {
|
||||
}, [filterValues])
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
setStatus("load")
|
||||
setTimeout(() => {
|
||||
setFilterValues(values)
|
||||
setRowSelection({})
|
||||
setStatus("done")
|
||||
}, 200)
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
@@ -149,7 +95,8 @@ export default function ChannelsPage() {
|
||||
// 批量续费
|
||||
const handleRenew = () => {
|
||||
const rows = getSelectedRows()
|
||||
console.log("续费选中数据:", rows)
|
||||
// TODO: 后端续费接口就绪后接入(见 src/api/http.ts rebuyOrder)
|
||||
toast.info(`已选择 ${rows.length} 条数据进行续费`)
|
||||
}
|
||||
|
||||
// 单个退货
|
||||
@@ -159,7 +106,8 @@ export default function ChannelsPage() {
|
||||
toast.error("请选择一条数据进行退货")
|
||||
return
|
||||
}
|
||||
console.log("退货数据:", rows[0])
|
||||
// TODO: 后端退货接口就绪后接入
|
||||
toast.info(`退货账号:${rows[0].account}`)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -257,7 +205,7 @@ export default function ChannelsPage() {
|
||||
</div>
|
||||
<DataTable<ChannelRecord>
|
||||
data={filteredData}
|
||||
status={status}
|
||||
status="done"
|
||||
columns={[
|
||||
{ accessorKey: "startAt", header: "开通时间" },
|
||||
{ accessorKey: "product", header: "产品" },
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
ClipboardList,
|
||||
Clock1,
|
||||
FolderCode,
|
||||
LayoutDashboard,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
ReceiptJapaneseYenIcon,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
ShoppingBag,
|
||||
TicketPercent,
|
||||
UserCheck,
|
||||
UserRound,
|
||||
} from "lucide-react"
|
||||
import { type ReactNode, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
@@ -150,16 +150,12 @@ export function Navbar() {
|
||||
<TooltipProvider>
|
||||
<NavItem
|
||||
href="/admin"
|
||||
icon={<LayoutDashboard size={20} />}
|
||||
label="账户总览"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/whitelist"
|
||||
icon={<UserCheck size={20} />}
|
||||
label="白名单"
|
||||
icon={<UserRound size={20} />}
|
||||
label="个人中心"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavTitle label="IP管理" />
|
||||
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
icon={<FolderCode size={20} />}
|
||||
@@ -172,6 +168,12 @@ export function Navbar() {
|
||||
label="资金明细"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/rosorder"
|
||||
icon={<Router size={20} />}
|
||||
label="软路由订单"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/orders"
|
||||
icon={<ShoppingBag size={20} />}
|
||||
@@ -181,21 +183,22 @@ export function Navbar() {
|
||||
<NavItem
|
||||
href="/admin/refundOrders"
|
||||
icon={<ClipboardList size={20} />}
|
||||
label="退货管理"
|
||||
label="退货订单管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/coupon"
|
||||
icon={<TicketPercent size={20} />}
|
||||
label="优惠券"
|
||||
label="我的优惠券"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/channels"
|
||||
href="/admin/cashOut"
|
||||
icon={<ReceiptJapaneseYenIcon size={20} />}
|
||||
label="提现管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavTitle label="HTTP管理" />
|
||||
<NavItem
|
||||
href="/admin/httpRecharge"
|
||||
icon={<Archive size={20} />}
|
||||
@@ -214,18 +217,24 @@ export function Navbar() {
|
||||
label="长效管理"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/rosorder"
|
||||
icon={<Router size={20} />}
|
||||
label="软路由"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/record"
|
||||
icon={<ClipboardClock size={20} />}
|
||||
label="使用记录"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/whitelist"
|
||||
icon={<UserCheck size={20} />}
|
||||
label="白名单"
|
||||
expand={navbar}
|
||||
/>
|
||||
<NavItem
|
||||
href="/admin/whitelistSU"
|
||||
icon={<UserCheck size={20} />}
|
||||
label="白名单(短效)"
|
||||
expand={navbar}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
</section>
|
||||
</nav>
|
||||
@@ -287,3 +296,31 @@ function NavItem(props: {
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function NavTitle(props: { label: string }) {
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
`transition-[height] duration-300 ease-in-out`,
|
||||
`text-sm text-gray-500 whitespace-nowrap flex items-center relative`,
|
||||
`group-data-[expand=true]:h-9`,
|
||||
`group-data-[expand=false]:h-4`,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
`transition-opacity duration-200 ease-in-out absolute mx-4`,
|
||||
`group-data-[expand=true]:delay-100 group-data-[expand=true]:opacity-100 group-data-[expand=false]:opacity-0`,
|
||||
)}
|
||||
>
|
||||
{props.label}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
`transition-opacity duration-200 ease-in-out absolute w-full border-b block`,
|
||||
`group-data-[expand=false]:delay-100 group-data-[expand=false]:opacity-100 group-data-[expand=true]:opacity-0`,
|
||||
)}
|
||||
></span>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,220 @@
|
||||
import { CheckCircle, Clock, Gift, Tag, Ticket, XCircle } from "lucide-react"
|
||||
import Page from "@/components/page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
|
||||
// TODO: 优惠券接口就绪后接入(见 src/api/coupon.ts fetchCouponPage 等)
|
||||
|
||||
interface Coupon {
|
||||
couponType: "Discount" | "Minus"
|
||||
couponValue: number
|
||||
name: string
|
||||
allowMinAmount: number
|
||||
}
|
||||
|
||||
interface UserCouponModel {
|
||||
coupon: Coupon
|
||||
orgin: {
|
||||
startTime: string
|
||||
endTime: string
|
||||
remark: string
|
||||
}
|
||||
isUsed: boolean
|
||||
}
|
||||
|
||||
// Mock 数据
|
||||
const mockCoupons: UserCouponModel[] = [
|
||||
{
|
||||
coupon: {
|
||||
couponType: "Discount",
|
||||
couponValue: 8,
|
||||
name: "八折优惠券",
|
||||
allowMinAmount: 100,
|
||||
},
|
||||
orgin: {
|
||||
startTime: "2026-01-01",
|
||||
endTime: "2026-12-31",
|
||||
remark: "新用户注册赠送",
|
||||
},
|
||||
isUsed: false,
|
||||
},
|
||||
{
|
||||
coupon: {
|
||||
couponType: "Minus",
|
||||
couponValue: 50,
|
||||
name: "满减优惠券",
|
||||
allowMinAmount: 200,
|
||||
},
|
||||
orgin: {
|
||||
startTime: "2026-06-01",
|
||||
endTime: "2026-09-30",
|
||||
remark: "活动期间领取",
|
||||
},
|
||||
isUsed: false,
|
||||
},
|
||||
{
|
||||
coupon: {
|
||||
couponType: "Discount",
|
||||
couponValue: 9,
|
||||
name: "九折优惠券",
|
||||
allowMinAmount: 0,
|
||||
},
|
||||
orgin: {
|
||||
startTime: "2026-08-01",
|
||||
endTime: "2026-10-01",
|
||||
remark: "VIP专享",
|
||||
},
|
||||
isUsed: true,
|
||||
},
|
||||
{
|
||||
coupon: {
|
||||
couponType: "Minus",
|
||||
couponValue: 20,
|
||||
name: "通用优惠券",
|
||||
allowMinAmount: 50,
|
||||
},
|
||||
orgin: {
|
||||
startTime: "2026-07-01",
|
||||
endTime: "2026-08-31",
|
||||
remark: "签到领取",
|
||||
},
|
||||
isUsed: false,
|
||||
},
|
||||
]
|
||||
|
||||
export default function CouponPage() {
|
||||
return <Page>优惠券</Page>
|
||||
const availableCoupons = mockCoupons.filter(item => !item.isUsed)
|
||||
const usedCoupons = mockCoupons.filter(item => item.isUsed)
|
||||
|
||||
const renderCoupon = (item: UserCouponModel) => {
|
||||
const isDiscount = item.coupon.couponType === "Discount"
|
||||
const isUsed = item.isUsed
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${item.coupon.name}-${item.orgin.startTime}`}
|
||||
className={`relative overflow-hidden transition-shadow hover:shadow-md ${isUsed ? "opacity-60" : ""}`}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* 优惠券左侧 - 面额 */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex min-w-20 flex-col items-center justify-center rounded-lg bg-primary/10 p-3">
|
||||
{isDiscount ? (
|
||||
<>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{item.coupon.couponValue}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">折</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">¥</span>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{item.coupon.couponValue}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 优惠券右侧 - 详情 */}
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-medium">{item.coupon.name}</h4>
|
||||
{isUsed ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
已使用
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="default" className="text-xs">
|
||||
<Ticket className="mr-1 h-3 w-3" />
|
||||
可使用
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="h-3 w-3" />
|
||||
{item.coupon.allowMinAmount > 0
|
||||
? `满${item.coupon.allowMinAmount}元可用`
|
||||
: "无限制"}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(item.orgin.startTime).toLocaleDateString()} -{" "}
|
||||
{new Date(item.orgin.endTime).toLocaleDateString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Gift className="h-3 w-3" />
|
||||
{item.orgin.remark}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 已使用水印效果 */}
|
||||
{isUsed && (
|
||||
<div className="absolute right-4 top-4">
|
||||
<XCircle className="h-6 w-6 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">我的优惠券</h3>
|
||||
<Badge variant="secondary" className="text-sm">
|
||||
共 {mockCoupons.length} 张
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="available" className="w-full">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="available">
|
||||
可使用 ({availableCoupons.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="used">
|
||||
已使用 ({usedCoupons.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="available" className="mt-4">
|
||||
{availableCoupons.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<Ticket className="mb-4 h-12 w-12 opacity-50" />
|
||||
<p>暂无可用优惠券</p>
|
||||
<p className="text-sm">快去参与活动领取吧</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{availableCoupons.map(renderCoupon)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="used" className="mt-4">
|
||||
{usedCoupons.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<CheckCircle className="mb-4 h-12 w-12 opacity-50" />
|
||||
<p>暂无已使用的优惠券</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{usedCoupons.map(renderCoupon)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
62
src/admin/dashboard/mock.ts
Normal file
62
src/admin/dashboard/mock.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
AccountInfo,
|
||||
ConsumptionStats,
|
||||
DashboardBalance,
|
||||
IpAccountStats,
|
||||
RecentOrderItem,
|
||||
} from "@/lib/models/dashboard"
|
||||
|
||||
// TODO: 后端仪表盘接口就绪后删除(见 src/api/dashboard.ts)
|
||||
|
||||
export const MOCK_BALANCE: DashboardBalance = {
|
||||
balance: 68.0,
|
||||
hCoin: 68.0,
|
||||
}
|
||||
|
||||
export const MOCK_ACCOUNT_INFO: AccountInfo = {
|
||||
phone: "191****1234",
|
||||
qq: "123456123",
|
||||
wechat: "123456123",
|
||||
email: "123456123@qq.com",
|
||||
taobao: "123456123",
|
||||
}
|
||||
|
||||
export const MOCK_RECENT_ORDERS: RecentOrderItem[] = [
|
||||
{
|
||||
date: "2025.04.01",
|
||||
product: "极光IP",
|
||||
plan: "天卡(尊享)",
|
||||
price: 3.0,
|
||||
connections: 1,
|
||||
account: "jx1234",
|
||||
orderAmount: 3.0,
|
||||
paidAmount: 3.0,
|
||||
},
|
||||
{
|
||||
date: "2025.04.01",
|
||||
product: "极光IP",
|
||||
plan: "天卡(尊享)",
|
||||
price: 3.0,
|
||||
connections: 1,
|
||||
account: "jx1234",
|
||||
orderAmount: 3.0,
|
||||
paidAmount: 3.0,
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_CONSUMPTION: ConsumptionStats = {
|
||||
today: 30.0,
|
||||
month: 30.0,
|
||||
todayRefund: 30.0,
|
||||
year: 30.0,
|
||||
todayRecharge: 30.0,
|
||||
monthRecharge: 30.0,
|
||||
monthRefund: 30.0,
|
||||
}
|
||||
|
||||
export const MOCK_IP_STATS: IpAccountStats = {
|
||||
using: 30,
|
||||
total: 50,
|
||||
expired: 20,
|
||||
yearConsumption: 30,
|
||||
}
|
||||
@@ -18,70 +18,30 @@ import b11 from "../assets/b11.webp"
|
||||
import b12 from "../assets/b12.webp"
|
||||
import banner from "../assets/banner.webp"
|
||||
import banner2 from "../assets/banner2.webp"
|
||||
import {
|
||||
MOCK_ACCOUNT_INFO,
|
||||
MOCK_BALANCE,
|
||||
MOCK_CONSUMPTION,
|
||||
MOCK_IP_STATS,
|
||||
MOCK_RECENT_ORDERS,
|
||||
} from "./mock"
|
||||
|
||||
|
||||
export default function DashboardPage() {
|
||||
// ========== 数据定义 ==========
|
||||
const balanceData = {
|
||||
balance: 68.0,
|
||||
hCoin: 68.0,
|
||||
}
|
||||
|
||||
const accountInfo = {
|
||||
phone: "191****1234",
|
||||
qq: "123456123",
|
||||
wechat: "123456123",
|
||||
email: "123456123@qq.com",
|
||||
taobao: "123456123",
|
||||
}
|
||||
|
||||
const recentOrders = [
|
||||
{
|
||||
date: "2025.04.01",
|
||||
product: "极光IP",
|
||||
plan: "天卡(尊享)",
|
||||
price: 3.0,
|
||||
connections: 1,
|
||||
account: "jx1234",
|
||||
orderAmount: 3.0,
|
||||
paidAmount: 3.0,
|
||||
},
|
||||
{
|
||||
date: "2025.04.01",
|
||||
product: "极光IP",
|
||||
plan: "天卡(尊享)",
|
||||
price: 3.0,
|
||||
connections: 1,
|
||||
account: "jx1234",
|
||||
orderAmount: 3.0,
|
||||
paidAmount: 3.0,
|
||||
},
|
||||
]
|
||||
|
||||
// 设计稿数据 - 全部显示30
|
||||
const consumptionData = {
|
||||
today: 30.0,
|
||||
month: 30.0,
|
||||
todayRefund: 30.0,
|
||||
year: 30.0,
|
||||
todayRecharge: 30.0,
|
||||
monthRecharge: 30.0,
|
||||
monthRefund: 30.0,
|
||||
}
|
||||
|
||||
const ipAccountData = {
|
||||
using: 30,
|
||||
total: 50,
|
||||
expired: 20,
|
||||
yearConsumption: 30,
|
||||
}
|
||||
// ========== 数据定义(待接口就绪后改为 useEffect 拉取) ==========
|
||||
const balanceData = MOCK_BALANCE
|
||||
const accountInfo = MOCK_ACCOUNT_INFO
|
||||
const recentOrders = MOCK_RECENT_ORDERS
|
||||
const consumptionData = MOCK_CONSUMPTION
|
||||
const ipAccountData = MOCK_IP_STATS
|
||||
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
|
||||
const handleSaveInfo = (data: EditInfoValues) => {
|
||||
console.log("保存的信息:", data)
|
||||
// TODO: 接入 src/api/user.ts updateUserProfile
|
||||
void data
|
||||
}
|
||||
|
||||
// ========== 渲染 ==========
|
||||
return (
|
||||
<Page
|
||||
className={cn(
|
||||
@@ -226,7 +186,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ====== 第二行:最近订单(占左侧3列) ====== */}
|
||||
{/* 可以优化掉不要 */}
|
||||
<div className="md:row-start-2 md:col-start-1 md:col-span-3 bg-white rounded-lg p-4 flex flex-col min-h-0">
|
||||
<h3 className="font-semibold text-lg mb-4 shrink-0">最近订单</h3>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
@@ -259,8 +219,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ====== 第三、四行:消费信息 + IP账号 ====== */}
|
||||
{/* 消费信息 - 占左侧3列,跨2行 */}
|
||||
{/* ====== 消费信息 + IP账号 ====== */}
|
||||
<div className="md:col-start-1 md:row-start-3 md:col-span-3 md:row-span-2 bg-white rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-lg">消费信息</h3>
|
||||
@@ -373,7 +332,7 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ====== IP账号(占右侧1列,跨2行) ====== */}
|
||||
{/* ====== IP账号 ====== */}
|
||||
<div className="md:col-start-4 md:row-start-3 md:row-span-2 bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg mb-6">IP账号</h3>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
|
||||
|
||||
|
||||
export default function FundsPage() {
|
||||
return (
|
||||
<Page>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
|
||||
|
||||
|
||||
export default function HttpRechargePage() {
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
|
||||
<DataTable
|
||||
<DataTable<HttpRechargeItem>
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
@@ -27,6 +30,7 @@ export default function HttpRechargePage() {
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import AccountCell from "@/components/composites/accountCell"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -12,49 +12,19 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useOrderFilters } from "@/lib/hooks/useOrderFilters"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
cutAccount,
|
||||
DEFAULT_FILTERS,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
formatOrderType,
|
||||
ORDER_TYPE_LABELS,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
PACKAGE_OPTIONS,
|
||||
totalConnections,
|
||||
} from "@/lib/models/order"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "../mock/orders"
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchOrderPage 返回的分页数据
|
||||
|
||||
const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
@@ -111,69 +81,18 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||
const keyword = filters.keyword.trim()
|
||||
|
||||
return list.filter(order => {
|
||||
if (
|
||||
filters.orderType !== "0" &&
|
||||
order.OrderType !== Number(filters.orderType)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.productId !== "0" &&
|
||||
order.ProductId !== Number(filters.productId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.packageName !== "0" &&
|
||||
order.PackageName !== filters.packageName
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
keyword &&
|
||||
!order.OrderNo.includes(keyword) &&
|
||||
!order.Accounts.includes(keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||
return false
|
||||
}
|
||||
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||
[appliedFilters],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const patch = (partial: Partial<OrderFilters>) =>
|
||||
setFilters({ ...filters, ...partial })
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
}
|
||||
const {
|
||||
filters,
|
||||
patch,
|
||||
handleSearch,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
handleSizeChange,
|
||||
filtered,
|
||||
paged,
|
||||
} = useOrderFilters(MOCK_ORDERS)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -278,10 +197,7 @@ export default function OrdersPage() {
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
onSizeChange: handleSizeChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
} from "@/lib/models/record"
|
||||
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
|
||||
|
||||
// TODO: 后端使用记录接口就绪后,将 mock 替换为 src/api/record.ts 的
|
||||
// fetchHttpUsedSummary / fetchHttpUsedLogs(服务端分页)
|
||||
|
||||
const columns: ColumnDef<HttpUsedLogItem>[] = [
|
||||
{
|
||||
accessorKey: "packType",
|
||||
|
||||
@@ -1,401 +0,0 @@
|
||||
import type { OrderItem } from "@/lib/models/order"
|
||||
|
||||
export const MOCK_ORDERS: OrderItem[] = [
|
||||
{
|
||||
Id: 23,
|
||||
OrderNo: "JU202508150001",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1234",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-15 10:23:45",
|
||||
UpdateTime: "2025-08-15 10:24:01",
|
||||
},
|
||||
{
|
||||
Id: 22,
|
||||
OrderNo: "JU202508150002",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1235",
|
||||
OrderAmount: 18,
|
||||
CouponAmount: 2,
|
||||
PaymentAmount: 16,
|
||||
CreateTime: "2025-08-15 09:12:33",
|
||||
UpdateTime: "2025-08-15 09:12:50",
|
||||
},
|
||||
{
|
||||
Id: 21,
|
||||
OrderNo: "JU202508140003",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1236",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-08-14 16:45:21",
|
||||
UpdateTime: "2025-08-14 16:45:39",
|
||||
},
|
||||
{
|
||||
Id: 20,
|
||||
OrderNo: "JU202508140004",
|
||||
OrderType: 3,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 5,
|
||||
AccountCount: 3,
|
||||
Accounts: "https-001,https-002,https-003",
|
||||
OrderAmount: 264,
|
||||
CouponAmount: 10,
|
||||
PaymentAmount: 254,
|
||||
CreateTime: "2025-08-14 14:02:17",
|
||||
UpdateTime: "2025-08-14 14:02:55",
|
||||
},
|
||||
{
|
||||
Id: 19,
|
||||
OrderNo: "JU202508140005",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-001",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-14 11:30:08",
|
||||
UpdateTime: "2025-08-14 11:30:26",
|
||||
},
|
||||
{
|
||||
Id: 18,
|
||||
OrderNo: "JU202508130006",
|
||||
OrderType: 4,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 10,
|
||||
AccountCount: 5,
|
||||
Accounts: "batch-jx-01,batch-jx-02,batch-jx-03,batch-jx-04,batch-jx-05",
|
||||
OrderAmount: 3440,
|
||||
CouponAmount: 100,
|
||||
PaymentAmount: 3340,
|
||||
CreateTime: "2025-08-13 20:15:44",
|
||||
UpdateTime: "2025-08-13 20:16:02",
|
||||
},
|
||||
{
|
||||
Id: 17,
|
||||
OrderNo: "JU202508130007",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 0.5,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1237",
|
||||
OrderAmount: 0.5,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 0.5,
|
||||
CreateTime: "2025-08-13 18:03:12",
|
||||
UpdateTime: "2025-08-13 18:03:28",
|
||||
},
|
||||
{
|
||||
Id: 16,
|
||||
OrderNo: "JU202508120008",
|
||||
OrderType: 2,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 128,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-004",
|
||||
OrderAmount: 128,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 128,
|
||||
CreateTime: "2025-08-12 15:22:37",
|
||||
UpdateTime: "2025-08-12 15:22:59",
|
||||
},
|
||||
{
|
||||
Id: 15,
|
||||
OrderNo: "JU202508120009",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-002",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 5,
|
||||
PaymentAmount: 93,
|
||||
CreateTime: "2025-08-12 13:40:55",
|
||||
UpdateTime: "2025-08-12 13:41:13",
|
||||
},
|
||||
{
|
||||
Id: 14,
|
||||
OrderNo: "JU202508110010",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1238",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-11 10:05:29",
|
||||
UpdateTime: "2025-08-11 10:05:44",
|
||||
},
|
||||
{
|
||||
Id: 13,
|
||||
OrderNo: "JU202508110011",
|
||||
OrderType: 3,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 18,
|
||||
ConnectCount: 4,
|
||||
AccountCount: 4,
|
||||
Accounts: "batch-jx-06,batch-jx-07,batch-jx-08,batch-jx-09",
|
||||
OrderAmount: 72,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 72,
|
||||
CreateTime: "2025-08-11 08:56:48",
|
||||
UpdateTime: "2025-08-11 08:57:06",
|
||||
},
|
||||
{
|
||||
Id: 12,
|
||||
OrderNo: "JU202508100012",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 168,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1239",
|
||||
OrderAmount: 168,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 168,
|
||||
CreateTime: "2025-08-10 19:33:20",
|
||||
UpdateTime: "2025-08-10 19:33:41",
|
||||
},
|
||||
{
|
||||
Id: 11,
|
||||
OrderNo: "JU202508090013",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 880,
|
||||
ConnectCount: 20,
|
||||
AccountCount: 2,
|
||||
Accounts: "https-005,https-006",
|
||||
OrderAmount: 1760,
|
||||
CouponAmount: 50,
|
||||
PaymentAmount: 1710,
|
||||
CreateTime: "2025-08-09 21:18:07",
|
||||
UpdateTime: "2025-08-09 21:18:30",
|
||||
},
|
||||
{
|
||||
Id: 10,
|
||||
OrderNo: "JU202508090014",
|
||||
OrderType: 1,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "测试卡",
|
||||
DayPrice: 1,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-003",
|
||||
OrderAmount: 1,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 1,
|
||||
CreateTime: "2025-08-09 17:47:52",
|
||||
UpdateTime: "2025-08-09 17:48:09",
|
||||
},
|
||||
{
|
||||
Id: 9,
|
||||
OrderNo: "JU202508080015",
|
||||
OrderType: 4,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 88,
|
||||
ConnectCount: 6,
|
||||
AccountCount: 6,
|
||||
Accounts: "https-b1,https-b2,https-b3,https-b4,https-b5,https-b6",
|
||||
OrderAmount: 528,
|
||||
CouponAmount: 20,
|
||||
PaymentAmount: 508,
|
||||
CreateTime: "2025-08-08 12:26:34",
|
||||
UpdateTime: "2025-08-08 12:26:58",
|
||||
},
|
||||
{
|
||||
Id: 8,
|
||||
OrderNo: "JU202508070016",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡(活动)",
|
||||
DayPrice: 58,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1240",
|
||||
OrderAmount: 58,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 58,
|
||||
CreateTime: "2025-08-07 09:11:26",
|
||||
UpdateTime: "2025-08-07 09:11:43",
|
||||
},
|
||||
{
|
||||
Id: 7,
|
||||
OrderNo: "JU202508060017",
|
||||
OrderType: 2,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "季卡",
|
||||
DayPrice: 268,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "global-004",
|
||||
OrderAmount: 268,
|
||||
CouponAmount: 8,
|
||||
PaymentAmount: 260,
|
||||
CreateTime: "2025-08-06 22:05:11",
|
||||
UpdateTime: "2025-08-06 22:05:35",
|
||||
},
|
||||
{
|
||||
Id: 6,
|
||||
OrderNo: "JU202508050018",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "天卡",
|
||||
DayPrice: 3,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1241",
|
||||
OrderAmount: 3,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 3,
|
||||
CreateTime: "2025-08-05 14:38:49",
|
||||
UpdateTime: "2025-08-05 14:39:05",
|
||||
},
|
||||
{
|
||||
Id: 5,
|
||||
OrderNo: "JU202508040019",
|
||||
OrderType: 3,
|
||||
ProductId: 3,
|
||||
ProductName: "HTTP/S5",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 3,
|
||||
AccountCount: 3,
|
||||
Accounts: "global-b1,global-b2,global-b3",
|
||||
OrderAmount: 294,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 294,
|
||||
CreateTime: "2025-08-04 10:52:18",
|
||||
UpdateTime: "2025-08-04 10:52:37",
|
||||
},
|
||||
{
|
||||
Id: 4,
|
||||
OrderNo: "JU202508030020",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "双月卡(活动)",
|
||||
DayPrice: 98,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1242",
|
||||
OrderAmount: 98,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 98,
|
||||
CreateTime: "2025-08-03 16:07:23",
|
||||
UpdateTime: "2025-08-03 16:07:44",
|
||||
},
|
||||
{
|
||||
Id: 3,
|
||||
OrderNo: "JU202508020021",
|
||||
OrderType: 2,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "年卡",
|
||||
DayPrice: 688,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1243",
|
||||
OrderAmount: 688,
|
||||
CouponAmount: 30,
|
||||
PaymentAmount: 658,
|
||||
CreateTime: "2025-08-02 20:29:56",
|
||||
UpdateTime: "2025-08-02 20:30:14",
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
OrderNo: "JU202508010022",
|
||||
OrderType: 1,
|
||||
ProductId: 2,
|
||||
ProductName: "新HTTP/S5",
|
||||
PackageName: "周卡",
|
||||
DayPrice: 25,
|
||||
ConnectCount: 2,
|
||||
AccountCount: 1,
|
||||
Accounts: "https-007",
|
||||
OrderAmount: 25,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 25,
|
||||
CreateTime: "2025-08-01 11:44:02",
|
||||
UpdateTime: "2025-08-01 11:44:21",
|
||||
},
|
||||
{
|
||||
Id: 1,
|
||||
OrderNo: "JU202507310023",
|
||||
OrderType: 1,
|
||||
ProductId: 1,
|
||||
ProductName: "动静态IP",
|
||||
PackageName: "月卡",
|
||||
DayPrice: 68,
|
||||
ConnectCount: 1,
|
||||
AccountCount: 1,
|
||||
Accounts: "jx1244",
|
||||
OrderAmount: 68,
|
||||
CouponAmount: 0,
|
||||
PaymentAmount: 68,
|
||||
CreateTime: "2025-07-31 09:58:37",
|
||||
UpdateTime: "2025-07-31 09:58:55",
|
||||
},
|
||||
]
|
||||
|
||||
export const MOCK_PRODUCTS = [
|
||||
{ id: 1, name: "动静态IP" },
|
||||
{ id: 2, name: "新HTTP/S5" },
|
||||
{ id: 3, name: "HTTP/S5" },
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { useMemo, useState } from "react"
|
||||
import AccountCell from "@/components/composites/accountCell"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -12,48 +12,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { useOrderFilters } from "@/lib/hooks/useOrderFilters"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import {
|
||||
cutAccount,
|
||||
DEFAULT_FILTERS,
|
||||
formatOrderType,
|
||||
type OrderFilters,
|
||||
formatDate,
|
||||
formatMoney,
|
||||
type OrderItem,
|
||||
PACKAGE_OPTIONS,
|
||||
totalConnections,
|
||||
} from "@/lib/models/order"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "../mock/orders"
|
||||
|
||||
function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchRefundOrders 返回的分页数据
|
||||
|
||||
const columns: ColumnDef<OrderItem>[] = [
|
||||
{
|
||||
@@ -65,11 +34,6 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
accessorKey: "OrderNo",
|
||||
header: "订单编号",
|
||||
},
|
||||
{
|
||||
accessorKey: "OrderType",
|
||||
header: "类型",
|
||||
cell: ({ row }) => formatOrderType(row.original.OrderType),
|
||||
},
|
||||
{
|
||||
accessorKey: "ProductName",
|
||||
header: "产品",
|
||||
@@ -110,69 +74,18 @@ const columns: ColumnDef<OrderItem>[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||
const keyword = filters.keyword.trim()
|
||||
|
||||
return list.filter(order => {
|
||||
if (
|
||||
filters.orderType !== "0" &&
|
||||
order.OrderType !== Number(filters.orderType)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.productId !== "0" &&
|
||||
order.ProductId !== Number(filters.productId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.packageName !== "0" &&
|
||||
order.PackageName !== filters.packageName
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
keyword &&
|
||||
!order.OrderNo.includes(keyword) &&
|
||||
!order.Accounts.includes(keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||
return false
|
||||
}
|
||||
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export default function RefundOrdersPage() {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||
[appliedFilters],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const patch = (partial: Partial<OrderFilters>) =>
|
||||
setFilters({ ...filters, ...partial })
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
}
|
||||
const {
|
||||
filters,
|
||||
patch,
|
||||
handleSearch,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
handleSizeChange,
|
||||
filtered,
|
||||
paged,
|
||||
} = useOrderFilters(MOCK_ORDERS)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
@@ -250,7 +163,7 @@ export default function RefundOrdersPage() {
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||
<h3 className="font-semibold text-lg">订单管理</h3>
|
||||
<h3 className="font-semibold text-lg">退款订单管理</h3>
|
||||
<DataTable
|
||||
data={paged}
|
||||
status="done"
|
||||
@@ -260,10 +173,7 @@ export default function RefundOrdersPage() {
|
||||
size: pageSize,
|
||||
total: filtered.length,
|
||||
onPageChange: setPage,
|
||||
onSizeChange: size => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
},
|
||||
onSizeChange: handleSizeChange,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
232
src/admin/resources/long-renew-dialog.tsx
Normal file
232
src/admin/resources/long-renew-dialog.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { calcOrderPrice, rebuyOrder } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
type CxPackage,
|
||||
type LangIpItem,
|
||||
PERIOD_OPTIONS,
|
||||
type RebuyData,
|
||||
} from "@/lib/models/package"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type LongRenewSource =
|
||||
| { kind: "package"; pkg: CxPackage }
|
||||
| { kind: "ip"; ip: LangIpItem }
|
||||
|
||||
function buildRebuyData(
|
||||
source: LongRenewSource | null,
|
||||
periodType: number,
|
||||
periodAmount: number,
|
||||
): { orderType: number; data: RebuyData } | null {
|
||||
if (!source) return null
|
||||
if (source.kind === "package") {
|
||||
const { pkg } = source
|
||||
return {
|
||||
orderType: 5,
|
||||
data: {
|
||||
packId: pkg.id,
|
||||
packType: 31,
|
||||
periodType,
|
||||
periodAmount,
|
||||
ipAmount: pkg.ipAmount,
|
||||
isAbroad: pkg.isAbroad2 ?? 0,
|
||||
isRelayed: pkg.isRelayed ?? 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
const { ip } = source
|
||||
return {
|
||||
orderType: 6,
|
||||
data: {
|
||||
packId: ip.packId,
|
||||
packType: 31,
|
||||
periodType,
|
||||
periodAmount,
|
||||
ipAmount: 1,
|
||||
ids: String(ip.id),
|
||||
isAbroad: ip.isAbroad,
|
||||
isRelayed: ip.isRelayed,
|
||||
shareType: ip.shareType2,
|
||||
lineType: ip.lineType2,
|
||||
bandwidth: ip.bandwidth,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function LongRenewDialog({
|
||||
open,
|
||||
source,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
source: LongRenewSource | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [periodType, setPeriodType] = useState(1)
|
||||
const [periodAmount, setPeriodAmount] = useState(1)
|
||||
const [money, setMoney] = useState(0)
|
||||
const [priceLoading, setPriceLoading] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
// 弹窗打开时重置选择
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPeriodType(1)
|
||||
setPeriodAmount(1)
|
||||
setMoney(0)
|
||||
seqRef.current += 1
|
||||
}, [open])
|
||||
|
||||
// 参数变化时重新计算价格
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const target = buildRebuyData(source, periodType, periodAmount)
|
||||
if (!target) return
|
||||
const seq = seqRef.current + 1
|
||||
seqRef.current = seq
|
||||
setPriceLoading(true)
|
||||
|
||||
void calcOrderPrice({
|
||||
order_type: target.orderType,
|
||||
money: 1,
|
||||
data: target.data,
|
||||
}).then(res => {
|
||||
if (seq !== seqRef.current) return
|
||||
setPriceLoading(false)
|
||||
if (!res.success) {
|
||||
setMoney(0)
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setMoney(
|
||||
Number((periodAmount * target.data.ipAmount * res.data).toFixed(2)),
|
||||
)
|
||||
})
|
||||
}, [open, source, periodType, periodAmount])
|
||||
|
||||
const title = source
|
||||
? source.kind === "package"
|
||||
? "长效IP订单续费"
|
||||
: "长效IP续费"
|
||||
: ""
|
||||
const periodUnit =
|
||||
PERIOD_OPTIONS.find(opt => opt.value === periodType)?.unit ?? "天"
|
||||
|
||||
const handlePay = async () => {
|
||||
const target = buildRebuyData(source, periodType, periodAmount)
|
||||
if (!target || money <= 0) {
|
||||
toast.error("价格计算失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setPaying(true)
|
||||
const res = await rebuyOrder({
|
||||
order_type: target.orderType,
|
||||
money,
|
||||
data: target.data,
|
||||
})
|
||||
setPaying(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.code === 1) {
|
||||
toast.success(res.data.msg || "续费成功")
|
||||
onClose()
|
||||
onSuccess()
|
||||
} else {
|
||||
toast.error(res.data.msg || "续费失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{source && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-2">
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
套餐周期:
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PERIOD_OPTIONS.map(opt => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={periodType === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriodType(opt.value)}
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
periodType === opt.value &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
购买时长:
|
||||
</h5>
|
||||
<div className="inline-flex items-center rounded-md border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => Math.max(1, v - 1))}
|
||||
disabled={periodAmount <= 1}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-6 py-2 text-sm font-medium tabular-nums bg-white min-w-24 text-center">
|
||||
{periodAmount}
|
||||
{periodUnit}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => v + 1)}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => void handlePay()}
|
||||
disabled={paying || priceLoading}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
{priceLoading
|
||||
? "价格计算中..."
|
||||
: paying
|
||||
? "支付中..."
|
||||
: `实付 ¥${money.toFixed(2)}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
243
src/admin/resources/long-switch-dialog.tsx
Normal file
243
src/admin/resources/long-switch-dialog.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { calcOrderPrice, changeIpOrder, fetchSwitchCities } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import type { GameCity } from "@/lib/models/http"
|
||||
import type { LangIpItem } from "@/lib/models/package"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ISP_OPTIONS = [
|
||||
{ value: 0, label: "普通线路" },
|
||||
{ value: 2, label: "电信" },
|
||||
{ value: 3, label: "移动" },
|
||||
{ value: 4, label: "联通" },
|
||||
]
|
||||
|
||||
export default function LongSwitchDialog({
|
||||
open,
|
||||
ip,
|
||||
pop,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
ip: LangIpItem | null
|
||||
pop: 0 | 1
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [cities, setCities] = useState<GameCity[]>([])
|
||||
const [isp, setIsp] = useState(0)
|
||||
const [cityCode, setCityCode] = useState(0)
|
||||
const [money, setMoney] = useState(0)
|
||||
const [priceLoading, setPriceLoading] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
// 打开时重置并加载可选城市
|
||||
useEffect(() => {
|
||||
if (!open || !ip) return
|
||||
setIsp(0)
|
||||
setCityCode(0)
|
||||
setCities([])
|
||||
seqRef.current += 1
|
||||
|
||||
void fetchSwitchCities(
|
||||
{
|
||||
gameId: ip.gameId,
|
||||
isAbroad: ip.isAbroad,
|
||||
shareType: ip.shareType2,
|
||||
isp: ip.isp,
|
||||
switchPop: pop,
|
||||
packId: ip.packId,
|
||||
},
|
||||
pop,
|
||||
).then(res => {
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setCities(res.data)
|
||||
})
|
||||
}, [open, ip, pop])
|
||||
|
||||
// 重新计算价格
|
||||
useEffect(() => {
|
||||
if (!open || !ip) return
|
||||
const seq = seqRef.current + 1
|
||||
seqRef.current = seq
|
||||
setPriceLoading(true)
|
||||
|
||||
const rebuyData = {
|
||||
packId: ip.packId,
|
||||
packType: 31,
|
||||
periodType: 1,
|
||||
periodAmount: 1,
|
||||
ipAmount: 1,
|
||||
ids: String(ip.id),
|
||||
isRelayed: ip.isRelayed,
|
||||
shareType: ip.shareType2,
|
||||
lineType: ip.lineType2,
|
||||
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)))
|
||||
},
|
||||
)
|
||||
}, [open, ip])
|
||||
|
||||
const title = pop === 1 ? "切换入口" : "长效IP切换节点"
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!ip || money <= 0) {
|
||||
toast.error("价格计算失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setPaying(true)
|
||||
const res = await changeIpOrder({
|
||||
order_type: 7,
|
||||
money,
|
||||
data: {
|
||||
title,
|
||||
id: ip.id,
|
||||
isAbroad: ip.isAbroad,
|
||||
cityCode,
|
||||
isp,
|
||||
switchPop: pop,
|
||||
data: {
|
||||
packId: ip.packId,
|
||||
packType: 31,
|
||||
periodType: 1,
|
||||
periodAmount: 1,
|
||||
ipAmount: 1,
|
||||
ids: String(ip.id),
|
||||
isRelayed: ip.isRelayed,
|
||||
shareType: ip.shareType2,
|
||||
lineType: ip.lineType2,
|
||||
bandwidth: ip.bandwidth,
|
||||
},
|
||||
},
|
||||
})
|
||||
setPaying(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.code === 1) {
|
||||
toast.success(res.data.msg || "切换成功")
|
||||
onClose()
|
||||
onSuccess()
|
||||
} else {
|
||||
toast.error(res.data.msg || "剩余线路不足,切换下单失败,请稍后再试")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
{ip && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-2">
|
||||
{ip.isAbroad === 0 && (
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
运营商选择:
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{ISP_OPTIONS.map(opt => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={isp === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setIsp(opt.value)}
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
isp === opt.value &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
地区选择:
|
||||
</h5>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant={cityCode === 0 ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCityCode(0)}
|
||||
className={cn(
|
||||
"rounded-md justify-center",
|
||||
cityCode === 0 &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
随机地区
|
||||
</Button>
|
||||
{cities.map(city => (
|
||||
<Button
|
||||
key={city.code}
|
||||
type="button"
|
||||
variant={cityCode === city.code ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setCityCode(city.code)}
|
||||
className={cn(
|
||||
"rounded-md justify-center",
|
||||
cityCode === city.code &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{city.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => void handlePay()}
|
||||
disabled={paying || priceLoading}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
{priceLoading
|
||||
? "价格计算中..."
|
||||
: paying
|
||||
? "下单中..."
|
||||
: `实付 ¥${money.toFixed(2)}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,440 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
fetchCxOrders,
|
||||
fetchCxPackages,
|
||||
fetchLangIpList,
|
||||
fetchLangIps,
|
||||
fetchSwitchList,
|
||||
} from "@/api/http"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import type {
|
||||
CxOrder,
|
||||
CxPackage,
|
||||
LangIpItem,
|
||||
SwitchRecord,
|
||||
} from "@/lib/models/package"
|
||||
import {
|
||||
formatAbroad,
|
||||
formatIpState,
|
||||
formatOrderTypeLabel,
|
||||
LONG_ORDER_TYPE_LABELS,
|
||||
parseExpired,
|
||||
} from "@/lib/models/package"
|
||||
import { downloadTextFile, estimateTotal } from "@/lib/utils"
|
||||
import LongRenewDialog, { type LongRenewSource } from "./long-renew-dialog"
|
||||
import LongSwitchDialog from "./long-switch-dialog"
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
export default function LongPage() {
|
||||
return <Page>长效管理</Page>
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState("ips")
|
||||
|
||||
// 已开通长效
|
||||
const [ipList, setIpList] = useState<LangIpItem[]>([])
|
||||
const [ipStatus, setIpStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [ipPage, setIpPage] = useState(1)
|
||||
const [ipTotal, setIpTotal] = useState(0)
|
||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
// 全部套餐
|
||||
const [packages, setPackages] = useState<CxPackage[]>([])
|
||||
const [packageStatus, setPackageStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
|
||||
// 购买记录
|
||||
const [orders, setOrders] = useState<CxOrder[]>([])
|
||||
const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
|
||||
// IP切换记录
|
||||
const [switchList, setSwitchList] = useState<SwitchRecord[]>([])
|
||||
const [switchStatus, setSwitchStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
const [switchPage, setSwitchPage] = useState(1)
|
||||
const [switchTotal, setSwitchTotal] = useState(0)
|
||||
|
||||
// 弹窗
|
||||
const [renewSource, setRenewSource] = useState<LongRenewSource | null>(null)
|
||||
const [switchTarget, setSwitchTarget] = useState<{
|
||||
ip: LangIpItem
|
||||
pop: 0 | 1
|
||||
} | null>(null)
|
||||
|
||||
const loadIps = useCallback(async (page: number) => {
|
||||
setIpStatus("load")
|
||||
const res = await fetchLangIpList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
packId: "",
|
||||
isAbroad: "",
|
||||
isExpired: 0,
|
||||
state: -1,
|
||||
ip: "",
|
||||
groupId: "",
|
||||
gameId: "",
|
||||
cityCode: "",
|
||||
orderBy: 0,
|
||||
})
|
||||
if (!res.success) {
|
||||
setIpStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setIpList(res.data.items)
|
||||
setIpTotal(
|
||||
estimateTotal(
|
||||
res.data.pager.totalCount,
|
||||
res.data.pager.currentPage || page,
|
||||
PAGE_SIZE,
|
||||
res.data.items.length,
|
||||
),
|
||||
)
|
||||
setIpStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadPackages = useCallback(async () => {
|
||||
setPackageStatus("load")
|
||||
const res = await fetchCxPackages()
|
||||
if (!res.success) {
|
||||
setPackageStatus("fail")
|
||||
return
|
||||
}
|
||||
setPackages(res.data)
|
||||
setPackageStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
setOrderStatus("load")
|
||||
const res = await fetchCxOrders()
|
||||
if (!res.success) {
|
||||
setOrderStatus("fail")
|
||||
return
|
||||
}
|
||||
setOrders(res.data)
|
||||
setOrderStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadSwitch = useCallback(async (page: number) => {
|
||||
setSwitchStatus("load")
|
||||
const res = await fetchSwitchList({
|
||||
page,
|
||||
limit: PAGE_SIZE,
|
||||
})
|
||||
if (!res.success) {
|
||||
setSwitchStatus("fail")
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setSwitchList(res.data.items)
|
||||
setSwitchTotal(
|
||||
estimateTotal(
|
||||
res.data.pager.totalCount,
|
||||
res.data.pager.currentPage || page,
|
||||
PAGE_SIZE,
|
||||
res.data.items.length,
|
||||
),
|
||||
)
|
||||
setSwitchStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void 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)
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setIpPage(page)
|
||||
setRowSelection({})
|
||||
void loadIps(page)
|
||||
}
|
||||
|
||||
const handleSwitchPageChange = (page: number) => {
|
||||
setSwitchPage(page)
|
||||
void loadSwitch(page)
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
const ids = Object.keys(rowSelection)
|
||||
if (ids.length === 0) {
|
||||
toast.error("请选择IP!")
|
||||
return
|
||||
}
|
||||
const res = await fetchLangIps(ids.join(","))
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.length === 0) {
|
||||
toast.error("提取失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
const content = res.data
|
||||
.map(
|
||||
ip =>
|
||||
`${ip.ip}|${ip.httpPort}|${ip.socks5Port}|${ip.username}|${ip.password}`,
|
||||
)
|
||||
.join("\n")
|
||||
downloadTextFile("ip.txt", content)
|
||||
toast.success("提取成功,已开始下载")
|
||||
}
|
||||
|
||||
const ipColumns = useMemo<ColumnDef<LangIpItem>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "isRelayed",
|
||||
header: "模式",
|
||||
cell: ({ row }) => (row.original.isRelayed ? "中继" : "直连"),
|
||||
},
|
||||
{ accessorKey: "ip", header: "节点入口IP" },
|
||||
{ accessorKey: "realIp", header: "隧道出口IP" },
|
||||
{
|
||||
id: "ports",
|
||||
header: "HTTP/SK5端口",
|
||||
cell: ({ row }) =>
|
||||
`${row.original.httpPort}/${row.original.socks5Port}`,
|
||||
},
|
||||
{ accessorKey: "area", header: "归属地" },
|
||||
{
|
||||
id: "bandwidth",
|
||||
header: "带宽",
|
||||
cell: ({ row }) =>
|
||||
`${row.original.bandwidth}M/${row.original.lineType}`,
|
||||
},
|
||||
{ accessorKey: "shareType", header: "线路" },
|
||||
{
|
||||
accessorKey: "state",
|
||||
header: "状态",
|
||||
cell: ({ row }) => formatIpState(row.original.state),
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
header: "账号/密码",
|
||||
cell: ({ row }) => `${row.original.username}/${row.original.password}`,
|
||||
},
|
||||
{ accessorKey: "expireTime", header: "过期时间" },
|
||||
{
|
||||
accessorKey: "isExpired",
|
||||
header: "是否过期",
|
||||
cell: ({ row }) =>
|
||||
parseExpired(row.original.isExpired) ? "已过期" : "未过期",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const ip = row.original
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setRenewSource({ kind: "ip", ip })}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
{ip.isAbroad === 1 && ip.isRelayed === 1 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSwitchTarget({ ip, pop: 1 })}
|
||||
>
|
||||
切换入口
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSwitchTarget({ ip, pop: 0 })}
|
||||
>
|
||||
切换节点
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const packageColumns = useMemo<ColumnDef<CxPackage>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "isAbroad",
|
||||
header: "套餐类型",
|
||||
cell: ({ row }) => formatAbroad(row.original.isAbroad),
|
||||
},
|
||||
{ accessorKey: "shareType", header: "模式" },
|
||||
{ accessorKey: "ipAmount", header: "可用IP数量(个)" },
|
||||
{ accessorKey: "expireTime", header: "过期时间" },
|
||||
{
|
||||
id: "actions",
|
||||
header: "续费",
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setRenewSource({ kind: "package", pkg: row.original })
|
||||
}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const orderColumns = useMemo<ColumnDef<CxOrder>[]>(
|
||||
() => [
|
||||
{ accessorKey: "order_no", header: "订单号" },
|
||||
{
|
||||
accessorKey: "order_type",
|
||||
header: "订单类型",
|
||||
cell: ({ row }) =>
|
||||
formatOrderTypeLabel(LONG_ORDER_TYPE_LABELS, row.original.order_type),
|
||||
},
|
||||
{ accessorKey: "game_type", header: "游戏类型" },
|
||||
{ accessorKey: "ip_count", header: "IP数量" },
|
||||
{ accessorKey: "money", header: "实付金额" },
|
||||
{ accessorKey: "create_time", header: "支付时间" },
|
||||
{
|
||||
id: "rebuy",
|
||||
header: "再次购买",
|
||||
cell: () => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/product/http")}
|
||||
>
|
||||
再次购买
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
)
|
||||
|
||||
const switchColumns = useMemo<ColumnDef<SwitchRecord>[]>(
|
||||
() => [
|
||||
{ accessorKey: "oldIp", header: "原IP" },
|
||||
{ accessorKey: "oldArea", header: "原属地" },
|
||||
{ accessorKey: "newIp", header: "切换后IP" },
|
||||
{ accessorKey: "newArea", header: "切换后属地" },
|
||||
{ accessorKey: "reason", header: "切换方式" },
|
||||
{ accessorKey: "createTime", header: "切换时间" },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg mb-4">长效套餐</h3>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="ips">已开通长效</TabsTrigger>
|
||||
<TabsTrigger value="packages">全部套餐</TabsTrigger>
|
||||
<TabsTrigger value="orders">购买记录</TabsTrigger>
|
||||
<TabsTrigger value="switch">IP切换记录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ips" className="pt-4 flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Button
|
||||
onClick={() => void handleExtract()}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
批量提取IP
|
||||
</Button>
|
||||
<span className="text-xs text-gray-500">
|
||||
格式:IP|http端口|socks5端口|账号|密码
|
||||
</span>
|
||||
</div>
|
||||
<DataTable
|
||||
data={ipList}
|
||||
status={ipStatus}
|
||||
columns={ipColumns}
|
||||
enableSelection
|
||||
rowSelection={rowSelection}
|
||||
onSelectionChange={setRowSelection}
|
||||
getRowId={row => String(row.id)}
|
||||
pagination={{
|
||||
page: ipPage - 1,
|
||||
size: PAGE_SIZE,
|
||||
total: ipTotal,
|
||||
sizeOptions: [PAGE_SIZE],
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="packages" className="pt-4">
|
||||
<DataTable
|
||||
data={packages}
|
||||
status={packageStatus}
|
||||
columns={packageColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders" className="pt-4">
|
||||
<DataTable
|
||||
data={orders}
|
||||
status={orderStatus}
|
||||
columns={orderColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="switch" className="pt-4">
|
||||
<DataTable
|
||||
data={switchList}
|
||||
status={switchStatus}
|
||||
columns={switchColumns}
|
||||
pagination={{
|
||||
page: switchPage - 1,
|
||||
size: PAGE_SIZE,
|
||||
total: switchTotal,
|
||||
sizeOptions: [PAGE_SIZE],
|
||||
onPageChange: handleSwitchPageChange,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<LongRenewDialog
|
||||
open={renewSource !== null}
|
||||
source={renewSource}
|
||||
onClose={() => setRenewSource(null)}
|
||||
onSuccess={() => {
|
||||
void loadIps(ipPage)
|
||||
void loadPackages()
|
||||
}}
|
||||
/>
|
||||
|
||||
<LongSwitchDialog
|
||||
open={switchTarget !== null}
|
||||
ip={switchTarget?.ip ?? null}
|
||||
pop={switchTarget?.pop ?? 0}
|
||||
onClose={() => setSwitchTarget(null)}
|
||||
onSuccess={() => void loadIps(ipPage)}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
490
src/admin/resources/short-api-link.tsx
Normal file
490
src/admin/resources/short-api-link.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { fetchUserApiToken } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
type ApiLinkConfig,
|
||||
type DxPackage,
|
||||
REGION_OPTIONS,
|
||||
SHORT_PLAN_UNLIMITED,
|
||||
type UserApiToken,
|
||||
} from "@/lib/models/package"
|
||||
|
||||
const API_LINK_BASE = "http://get.ip.juip.com/get/ip"
|
||||
|
||||
const DURATION_OPTIONS = [
|
||||
{ value: "0", label: "默认" },
|
||||
{ value: "1", label: "1分钟以上" },
|
||||
{ value: "2", label: "2分钟以上" },
|
||||
{ value: "3", label: "3分钟以上" },
|
||||
{ value: "4", label: "4分钟以上" },
|
||||
]
|
||||
|
||||
const PROTOCOL_OPTIONS = [
|
||||
{ value: "1", label: "HTTP" },
|
||||
{ value: "2", label: "SOCKS5" },
|
||||
]
|
||||
|
||||
const FORMAT_OPTIONS = [
|
||||
{ value: "1", label: "TXT" },
|
||||
{ value: "2", label: "JSON" },
|
||||
{ value: "3", label: "HTML" },
|
||||
]
|
||||
|
||||
const LINE_BREAK_OPTIONS = [
|
||||
{ value: "1", label: "回车换行(\\r\\n)" },
|
||||
{ value: "3", label: "回车(\\r)" },
|
||||
{ value: "4", label: "换行(\\n)" },
|
||||
{ value: "5", label: "Tab(\\t)" },
|
||||
{ value: "6", label: "其他符号:" },
|
||||
]
|
||||
|
||||
const ISP_OPTIONS = [
|
||||
{ value: "0", label: "不限" },
|
||||
{ value: "4", label: "联通" },
|
||||
{ value: "2", label: "电信" },
|
||||
]
|
||||
|
||||
const AUTH_MODE_OPTIONS = [
|
||||
{ value: "1", label: "白名单" },
|
||||
{ value: "2", label: "白名单+账密" },
|
||||
{ value: "3", label: "仅账号密码" },
|
||||
]
|
||||
|
||||
const SEPARATOR_OPTIONS = [
|
||||
{ value: "1", label: "竖线(|)" },
|
||||
{ value: "2", label: "斜线(/)" },
|
||||
{ value: "3", label: "冒号(:)" },
|
||||
{ value: "6", label: "自定义:" },
|
||||
]
|
||||
|
||||
type FieldRowProps = {
|
||||
label: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function FieldRow({ label, children }: FieldRowProps) {
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-2 sm:gap-4">
|
||||
<div className="sm:w-32 shrink-0 text-sm text-gray-600 sm:text-right pt-1">
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-wrap items-center gap-x-6 gap-y-2">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioItem({
|
||||
idPrefix,
|
||||
value,
|
||||
label,
|
||||
}: {
|
||||
idPrefix: string
|
||||
value: string
|
||||
label: string
|
||||
}) {
|
||||
const id = `${idPrefix}-${value}`
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value={value} id={id} />
|
||||
<Label htmlFor={id} className="text-sm cursor-pointer font-normal">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ShortApiLink({ packages }: { packages: DxPackage[] }) {
|
||||
const [packId, setPackId] = useState<string>("")
|
||||
const [config, setConfig] = useState<ApiLinkConfig>({
|
||||
num: 1,
|
||||
gt: 0,
|
||||
port: 1,
|
||||
type: 1,
|
||||
lb: 1,
|
||||
distinct: 2,
|
||||
isp: 0,
|
||||
aw: 0,
|
||||
at: 1,
|
||||
sp: 1,
|
||||
})
|
||||
const [regions, setRegions] = useState<string[]>([])
|
||||
const [token, setToken] = useState<UserApiToken | null>(null)
|
||||
const [link, setLink] = useState("")
|
||||
|
||||
useEffect(() => {
|
||||
void fetchUserApiToken().then(res => {
|
||||
if (res.success) setToken(res.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const currentPackage = packages.find(p => String(p.id) === packId)
|
||||
const isUnlimited = config.pt === SHORT_PLAN_UNLIMITED
|
||||
|
||||
const patchConfig = (partial: Partial<ApiLinkConfig>) =>
|
||||
setConfig(prev => ({ ...prev, ...partial }))
|
||||
|
||||
// 切换套餐:联动套餐类型与有效时长
|
||||
const handlePackageChange = (value: string) => {
|
||||
setPackId(value)
|
||||
const pkg = packages.find(p => String(p.id) === value)
|
||||
if (!pkg) return
|
||||
patchConfig({
|
||||
pack: pkg.id,
|
||||
pt: pkg.planType,
|
||||
time: pkg.durationType2,
|
||||
...(pkg.planType === SHORT_PLAN_UNLIMITED ? { distinct: 0 } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
const toggleRegion = (value: string, checked: boolean) => {
|
||||
setRegions(prev =>
|
||||
checked ? [...prev, value] : prev.filter(v => v !== value),
|
||||
)
|
||||
}
|
||||
|
||||
const buildLink = () => {
|
||||
const params: string[] = []
|
||||
const push = (key: string, value: unknown) => {
|
||||
if (value === undefined || value === null || value === "") return
|
||||
params.push(`${key}=${String(value)}`)
|
||||
}
|
||||
push("pack", config.pack)
|
||||
push("pt", config.pt)
|
||||
push("time", config.time)
|
||||
push("num", config.num)
|
||||
push("gt", config.gt)
|
||||
push("port", config.port)
|
||||
push("type", config.type)
|
||||
push("lb", config.lb)
|
||||
push("clb", config.clb)
|
||||
push("distinct", config.distinct)
|
||||
push("isp", config.isp)
|
||||
push("aw", config.aw)
|
||||
if (config.aw === 1 && token) {
|
||||
push("u", token.uid)
|
||||
push("t", token.token)
|
||||
}
|
||||
push("at", config.at)
|
||||
push("sp", config.sp)
|
||||
push("csp", config.csp)
|
||||
if (regions.length > 0) push("regions", regions.join(","))
|
||||
return `${API_LINK_BASE}?${params.join("&")}`
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!packId) {
|
||||
toast.error("请选择套餐")
|
||||
return
|
||||
}
|
||||
setLink(buildLink())
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!packId) {
|
||||
toast.error("请选择套餐")
|
||||
return
|
||||
}
|
||||
const url = link || buildLink()
|
||||
setLink(url)
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
toast.success("复制成功")
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制")
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
if (!packId) {
|
||||
toast.error("请选择套餐")
|
||||
return
|
||||
}
|
||||
const url = link || buildLink()
|
||||
setLink(url)
|
||||
window.open(url, "_blank")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FieldRow label="请选择提取类型">
|
||||
<Select value={packId} onValueChange={handlePackageChange}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue placeholder="请选择套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{packages.map(item => (
|
||||
<SelectItem key={item.id} value={String(item.id)}>
|
||||
({item.id}){item.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="提取数量">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={config.num}
|
||||
onChange={e =>
|
||||
patchConfig({
|
||||
num: Math.min(200, Math.max(1, Number(e.target.value) || 1)),
|
||||
})
|
||||
}
|
||||
className="w-24"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
个IP{" "}
|
||||
<span className="text-gray-400">单次可提取最大数量 200 个</span>
|
||||
</span>
|
||||
</div>
|
||||
</FieldRow>
|
||||
|
||||
{(isUnlimited || config.pt === 21) && (
|
||||
<FieldRow label="提取时长">
|
||||
<RadioGroup
|
||||
value={String(config.gt)}
|
||||
onValueChange={v => patchConfig({ gt: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{DURATION_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="gt"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
<FieldRow label="IP协议">
|
||||
<RadioGroup
|
||||
value={String(config.port)}
|
||||
onValueChange={v => patchConfig({ port: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{PROTOCOL_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="port"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="数据格式">
|
||||
<RadioGroup
|
||||
value={String(config.type)}
|
||||
onValueChange={v => patchConfig({ type: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{FORMAT_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="type"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="换行符">
|
||||
<RadioGroup
|
||||
value={String(config.lb)}
|
||||
onValueChange={v => patchConfig({ lb: Number(v) })}
|
||||
className="flex flex-wrap items-center"
|
||||
>
|
||||
{LINE_BREAK_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="lb"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
value={config.clb ?? ""}
|
||||
onChange={e => patchConfig({ clb: e.target.value })}
|
||||
placeholder="请输入换行符"
|
||||
className="w-36 h-8"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="地区选择">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex gap-4 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegions(REGION_OPTIONS.map(r => r.value))}
|
||||
className="text-blue-500 hover:underline cursor-pointer"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegions([])}
|
||||
className="text-blue-500 hover:underline cursor-pointer"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-5 gap-y-2">
|
||||
{REGION_OPTIONS.map(region => (
|
||||
<label
|
||||
key={region.value}
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Checkbox
|
||||
checked={regions.includes(region.value)}
|
||||
onCheckedChange={checked =>
|
||||
toggleRegion(region.value, checked === true)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{region.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="IP去重">
|
||||
{isUnlimited ? (
|
||||
<span className="text-sm text-gray-600">不去重</span>
|
||||
) : (
|
||||
<RadioGroup
|
||||
value={String(config.distinct)}
|
||||
onValueChange={v => patchConfig({ distinct: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
<RadioItem idPrefix="distinct" value="2" label="严格去重" />
|
||||
<RadioItem idPrefix="distinct" value="0" label="不去重" />
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="运营商">
|
||||
<RadioGroup
|
||||
value={String(config.isp)}
|
||||
onValueChange={v => patchConfig({ isp: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{ISP_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="isp"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
<FieldRow label="自动添加白名单">
|
||||
<RadioGroup
|
||||
value={String(config.aw)}
|
||||
onValueChange={v => patchConfig({ aw: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
<RadioItem idPrefix="aw" value="0" label="不自动" />
|
||||
<RadioItem idPrefix="aw" value="1" label="自动" />
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
|
||||
{!isUnlimited && (
|
||||
<FieldRow label="签权模式">
|
||||
<RadioGroup
|
||||
value={String(config.at)}
|
||||
onValueChange={v => patchConfig({ at: Number(v) })}
|
||||
className="flex flex-wrap"
|
||||
>
|
||||
{AUTH_MODE_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="at"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
{config.at !== 1 && !isUnlimited && (
|
||||
<FieldRow label="分隔符">
|
||||
<RadioGroup
|
||||
value={String(config.sp)}
|
||||
onValueChange={v => patchConfig({ sp: Number(v) })}
|
||||
className="flex flex-wrap items-center"
|
||||
>
|
||||
{SEPARATOR_OPTIONS.map(opt => (
|
||||
<RadioItem
|
||||
key={opt.value}
|
||||
idPrefix="sp"
|
||||
value={opt.value}
|
||||
label={opt.label}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
value={config.csp ?? ""}
|
||||
onChange={e => patchConfig({ csp: e.target.value })}
|
||||
placeholder="请输入分割符"
|
||||
className="w-36 h-8"
|
||||
/>
|
||||
</RadioGroup>
|
||||
</FieldRow>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center pt-2">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
size="lg"
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
生成API链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<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>
|
||||
<Button onClick={handleOpen} variant="outline">
|
||||
打开链接
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentPackage && (
|
||||
<p className="text-xs text-gray-400">
|
||||
已选套餐:({currentPackage.id}){currentPackage.name}
|
||||
,套餐类型 {currentPackage.planType},有效时长{" "}
|
||||
{currentPackage.durationType}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
219
src/admin/resources/short-renew-dialog.tsx
Normal file
219
src/admin/resources/short-renew-dialog.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { calcOrderPrice, rebuyOrder } from "@/api/http"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
type DxPackage,
|
||||
PERIOD_OPTIONS,
|
||||
SHORT_PLAN_DAILY,
|
||||
SHORT_PLAN_UNLIMITED,
|
||||
} from "@/lib/models/package"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function orderTypeOf(pkg: DxPackage): number {
|
||||
return pkg.planType === SHORT_PLAN_DAILY ? 3 : 2
|
||||
}
|
||||
|
||||
export default function ShortRenewDialog({
|
||||
open,
|
||||
pkg,
|
||||
whitelist,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean
|
||||
pkg: DxPackage | null
|
||||
whitelist: number
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const [periodType, setPeriodType] = useState(1)
|
||||
const [periodAmount, setPeriodAmount] = useState(1)
|
||||
const [money, setMoney] = useState(0)
|
||||
const [priceLoading, setPriceLoading] = useState(false)
|
||||
const [paying, setPaying] = useState(false)
|
||||
const seqRef = useRef(0)
|
||||
|
||||
// 弹窗打开时重置选择
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPeriodType(1)
|
||||
setPeriodAmount(1)
|
||||
setMoney(0)
|
||||
seqRef.current += 1
|
||||
}, [open])
|
||||
|
||||
// 参数变化时重新计算价格
|
||||
useEffect(() => {
|
||||
if (!open || !pkg) return
|
||||
const seq = seqRef.current + 1
|
||||
seqRef.current = seq
|
||||
|
||||
const orderType = orderTypeOf(pkg)
|
||||
setPriceLoading(true)
|
||||
|
||||
void calcOrderPrice({
|
||||
order_type: orderType,
|
||||
money: 1,
|
||||
data: {
|
||||
packId: pkg.id,
|
||||
packType: pkg.planType,
|
||||
periodType,
|
||||
periodAmount,
|
||||
durationType: pkg.durationType2,
|
||||
ipAmount: pkg.ipAmount ?? 0,
|
||||
},
|
||||
}).then(res => {
|
||||
if (seq !== seqRef.current) return
|
||||
setPriceLoading(false)
|
||||
if (!res.success) {
|
||||
setMoney(0)
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
|
||||
let price = 0
|
||||
if (orderType === 2) {
|
||||
// 短效无限量:白名单越多折扣越高
|
||||
const discount = Math.max(0.6, 1 - 0.1 * (whitelist - 1))
|
||||
price = periodAmount * whitelist * discount * res.data
|
||||
} else {
|
||||
price = periodAmount * res.data * (pkg.ipAmount ?? 0)
|
||||
}
|
||||
setMoney(Number(price.toFixed(2)))
|
||||
})
|
||||
}, [open, pkg, periodType, periodAmount, whitelist])
|
||||
|
||||
const periodUnit =
|
||||
PERIOD_OPTIONS.find(opt => opt.value === periodType)?.unit ?? "天"
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!pkg) return
|
||||
if (money <= 0) {
|
||||
toast.error("价格计算失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setPaying(true)
|
||||
const res = await rebuyOrder({
|
||||
order_type: orderTypeOf(pkg),
|
||||
money,
|
||||
data: {
|
||||
packId: pkg.id,
|
||||
packType: pkg.planType,
|
||||
periodType,
|
||||
periodAmount,
|
||||
durationType: pkg.durationType2,
|
||||
ipAmount: pkg.ipAmount ?? 0,
|
||||
},
|
||||
})
|
||||
setPaying(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
if (res.data.code === 1) {
|
||||
toast.success(res.data.msg || "续费成功")
|
||||
onClose()
|
||||
onSuccess()
|
||||
} else {
|
||||
toast.error(res.data.msg || "续费失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={open => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{pkg && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{pkg.name} - 续费</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6 py-2">
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
套餐周期:
|
||||
</h5>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PERIOD_OPTIONS.filter(
|
||||
opt =>
|
||||
!(pkg.planType === SHORT_PLAN_DAILY && opt.value === 90),
|
||||
).map(opt => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant={periodType === opt.value ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setPeriodType(opt.value)}
|
||||
className={cn(
|
||||
"rounded-md",
|
||||
periodType === opt.value &&
|
||||
"bg-linear-to-r from-blue-500 to-cyan-400 border-transparent hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-3">
|
||||
购买时长:
|
||||
</h5>
|
||||
<div className="inline-flex items-center rounded-md border overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => Math.max(1, v - 1))}
|
||||
disabled={periodAmount <= 1}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="px-6 py-2 text-sm font-medium tabular-nums bg-white min-w-24 text-center">
|
||||
{periodAmount}
|
||||
{periodUnit}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPeriodAmount(v => v + 1)}
|
||||
className="px-4 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{pkg.planType === SHORT_PLAN_UNLIMITED && (
|
||||
<p className="text-xs text-gray-500 -mt-2">
|
||||
无限量白名单上限:{whitelist} 个
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => void handlePay()}
|
||||
disabled={paying || priceLoading}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
{priceLoading
|
||||
? "价格计算中..."
|
||||
: paying
|
||||
? "支付中..."
|
||||
: `实付 ¥${money.toFixed(2)}`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,180 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { fetchDxOrders, fetchDxPackages, fetchWhiteLimitMax } from "@/api/http"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import {
|
||||
type DxOrder,
|
||||
type DxPackage,
|
||||
formatExpired,
|
||||
formatOrderTypeLabel,
|
||||
SHORT_ORDER_TYPE_LABELS,
|
||||
SHORT_PLAN_VOLUME,
|
||||
} from "@/lib/models/package"
|
||||
import ShortApiLink from "./short-api-link"
|
||||
import ShortRenewDialog from "./short-renew-dialog"
|
||||
|
||||
export default function ShortPage() {
|
||||
return <Page>短效套餐</Page>
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState("packages")
|
||||
|
||||
const [packages, setPackages] = useState<DxPackage[]>([])
|
||||
const [orders, setOrders] = useState<DxOrder[]>([])
|
||||
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
|
||||
const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">(
|
||||
"load",
|
||||
)
|
||||
const [whitelist, setWhitelist] = useState(1)
|
||||
const [renewPackage, setRenewPackage] = useState<DxPackage | null>(null)
|
||||
|
||||
const loadPackages = useCallback(async () => {
|
||||
setStatus("load")
|
||||
const res = await fetchDxPackages()
|
||||
if (!res.success) {
|
||||
setStatus("fail")
|
||||
return
|
||||
}
|
||||
setPackages(res.data)
|
||||
setStatus("done")
|
||||
}, [])
|
||||
|
||||
const loadOrders = useCallback(async () => {
|
||||
setOrderStatus("load")
|
||||
const res = await fetchDxOrders()
|
||||
if (!res.success) {
|
||||
setOrderStatus("fail")
|
||||
return
|
||||
}
|
||||
setOrders(res.data)
|
||||
setOrderStatus("done")
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void loadPackages()
|
||||
void 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()
|
||||
}
|
||||
|
||||
const packageColumns = useMemo<ColumnDef<DxPackage>[]>(
|
||||
() => [
|
||||
{ accessorKey: "name", header: "套餐类型" },
|
||||
{
|
||||
accessorKey: "remainingAmount",
|
||||
header: "当日可用IP数量(个)",
|
||||
cell: ({ row }) => String(row.original.remainingAmount ?? "-"),
|
||||
},
|
||||
{ accessorKey: "durationType", header: "有效时长" },
|
||||
{ accessorKey: "expireTime", header: "过期时间" },
|
||||
{
|
||||
accessorKey: "isExpired",
|
||||
header: "是否过期",
|
||||
cell: ({ row }) => formatExpired(row.original.isExpired),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "续费",
|
||||
cell: ({ row }) => {
|
||||
const pkg = row.original
|
||||
if (pkg.planType === SHORT_PLAN_VOLUME) {
|
||||
return <span className="text-gray-400">不支持续费</span>
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setRenewPackage(pkg)}
|
||||
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const orderColumns = useMemo<ColumnDef<DxOrder>[]>(
|
||||
() => [
|
||||
{ accessorKey: "order_no", header: "订单号" },
|
||||
{
|
||||
accessorKey: "order_type",
|
||||
header: "订单类型",
|
||||
cell: ({ row }) =>
|
||||
formatOrderTypeLabel(
|
||||
SHORT_ORDER_TYPE_LABELS,
|
||||
row.original.order_type,
|
||||
),
|
||||
},
|
||||
{ accessorKey: "package_type", header: "套餐类型" },
|
||||
{ accessorKey: "durationType", header: "有效时长" },
|
||||
{ accessorKey: "money", header: "支付金额" },
|
||||
{ accessorKey: "create_time", header: "支付时间" },
|
||||
{
|
||||
id: "rebuy",
|
||||
header: "再次购买",
|
||||
cell: () => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/product/http")}
|
||||
>
|
||||
再次购买
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
)
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg mb-4">短效套餐</h3>
|
||||
<Tabs value={activeTab} onValueChange={handleTabChange}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="packages">全部套餐</TabsTrigger>
|
||||
<TabsTrigger value="orders">购买记录</TabsTrigger>
|
||||
<TabsTrigger value="extract">提取短效</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="packages" className="pt-4">
|
||||
<DataTable
|
||||
data={packages}
|
||||
status={status}
|
||||
columns={packageColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="orders" className="pt-4">
|
||||
<DataTable
|
||||
data={orders}
|
||||
status={orderStatus}
|
||||
columns={orderColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="extract" className="pt-4">
|
||||
<ShortApiLink packages={packages} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<ShortRenewDialog
|
||||
open={renewPackage !== null}
|
||||
pkg={renewPackage}
|
||||
whitelist={whitelist}
|
||||
onClose={() => setRenewPackage(null)}
|
||||
onSuccess={() => void loadPackages()}
|
||||
/>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import type { RosOrderItem } from "@/lib/models/rosorder"
|
||||
|
||||
// TODO: 软路由订单接口就绪后接入(见 src/api/rosorder.ts fetchRosOrderList)
|
||||
|
||||
export default function RosorderPage() {
|
||||
return <Page>软路由页面</Page>
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<h3 className="font-semibold text-lg">软路由订单列表</h3>
|
||||
<DataTable<RosOrderItem>
|
||||
data={[]}
|
||||
status="done"
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "用户",
|
||||
},
|
||||
{
|
||||
accessorKey: "product_name",
|
||||
header: "软路由",
|
||||
},
|
||||
{
|
||||
accessorKey: "real_price",
|
||||
header: "实付",
|
||||
},
|
||||
{
|
||||
accessorKey: "order_no",
|
||||
header: "订单编号",
|
||||
},
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: "收货人姓名",
|
||||
},
|
||||
{
|
||||
accessorKey: "phone",
|
||||
header: "收货人电话",
|
||||
},
|
||||
{
|
||||
accessorKey: "address",
|
||||
header: "收货人地址",
|
||||
},
|
||||
{
|
||||
accessorKey: "create_time",
|
||||
header: "购买时间",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,127 @@
|
||||
import { Lock, Trash2, Unlock } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
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 { WhiteIpItem } from "@/lib/models/whitelist"
|
||||
|
||||
// TODO: 后端白名单接口就绪后,将 saveWhiteIp/fetchWhiteList 接入(见 src/api/whitelist.ts)
|
||||
|
||||
export default function WhitelistPage() {
|
||||
return <Page>白名单</Page>
|
||||
const [ip, setIp] = useState("")
|
||||
const [list, setList] = useState<WhiteIpItem[]>([])
|
||||
|
||||
const handleSave = () => {
|
||||
const value = ip.trim()
|
||||
if (!value) return
|
||||
// TODO: await saveWhiteIp(value)
|
||||
setList(prev => [
|
||||
{ id: Date.now(), ip: value, createTime: "", isLocked: "未锁定" },
|
||||
...prev,
|
||||
])
|
||||
setIp("")
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={ip}
|
||||
onChange={e => setIp(e.target.value)}
|
||||
onKeyDown={e => e.key === "Enter" && handleSave()}
|
||||
placeholder="请输入IP地址并点击保存白名单"
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<Button onClick={handleSave}>保存</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">当前白名单列表</h3>
|
||||
<Badge variant="secondary" className="text-yellow-600">
|
||||
当前仅允许保留白名单数量:5个
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<DataTable<WhiteIpItem>
|
||||
data={list}
|
||||
status="done"
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP地址",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "设置时间",
|
||||
},
|
||||
{
|
||||
accessorKey: "isLocked",
|
||||
header: "锁定状态",
|
||||
cell: ({ row }) => {
|
||||
const isLocked = row.original.isLocked === "已锁定"
|
||||
return (
|
||||
<Badge variant={isLocked ? "destructive" : "outline"}>
|
||||
{row.original.isLocked}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
// TODO: 接入 lockWhiteIp / deleteWhiteIp
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.isLocked === "未锁定" ? (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{item.isLocked === "未锁定" ? "锁定" : "解锁"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setList(prev => prev.filter(i => i.id !== item.id))
|
||||
}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
|
||||
226
src/admin/whitelistSU/page.tsx
Normal file
226
src/admin/whitelistSU/page.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Lock, Minus, Plus, Trash2, Unlock } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import DataTable from "@/components/data-table"
|
||||
import Page from "@/components/page"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
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)
|
||||
|
||||
const clampLimit = (value: number) => Math.min(20, Math.max(1, value))
|
||||
|
||||
const discountOf = (limit: number) => Math.max(6, 11 - limit)
|
||||
|
||||
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 [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
|
||||
// 调整后到期时间(演示用固定 +30 天)
|
||||
const newExpire = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
|
||||
|
||||
const setTempWhiteLimitHandler = (value: number) => {
|
||||
const clamped = clampLimit(value)
|
||||
setTempWhiteLimit(clamped)
|
||||
setTempDiscount(discountOf(clamped))
|
||||
}
|
||||
|
||||
const increaseLimit = () => setTempWhiteLimitHandler(tempWhiteLimit + 1)
|
||||
|
||||
const decreaseLimit = () => setTempWhiteLimitHandler(tempWhiteLimit - 1)
|
||||
|
||||
const openDialog = () => {
|
||||
setTempWhiteLimit(whiteLimit)
|
||||
setTempDiscount(discountOf(whiteLimit))
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const confirmSetLimit = () => {
|
||||
setWhiteLimit(tempWhiteLimit)
|
||||
setIsDialogOpen(false)
|
||||
// TODO: await setWhiteLimitMax(tempWhiteLimit)
|
||||
}
|
||||
|
||||
const handleSaveIp = () => {
|
||||
const value = ip.trim()
|
||||
if (!value) return
|
||||
// TODO: await saveWhiteIp(value)
|
||||
setList(prev => [
|
||||
{ id: Date.now(), ip: value, createTime: "", isLocked: "未锁定" },
|
||||
...prev,
|
||||
])
|
||||
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 removeIp = (item: WhiteIpItem) => {
|
||||
// TODO: await deleteWhiteIp(item.id)
|
||||
setList(prev => prev.filter(i => i.id !== item.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="bg-white rounded-lg p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={ip}
|
||||
onChange={e => setIp(e.target.value)}
|
||||
placeholder="请输入IP地址并点击保存白名单"
|
||||
className="max-w-sm"
|
||||
onKeyDown={e => e.key === "Enter" && handleSaveIp()}
|
||||
/>
|
||||
<Button onClick={handleSaveIp}>保存</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="font-semibold text-lg">当前白名单列表</h3>
|
||||
<Badge variant="secondary" className="text-yellow-600">
|
||||
当前白名单数量:{whiteLimit} 个
|
||||
</Badge>
|
||||
<Button variant="outline" size="sm" onClick={openDialog}>
|
||||
调整白名单上限
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable<WhiteIpItem>
|
||||
data={list}
|
||||
status="done"
|
||||
columns={[
|
||||
{
|
||||
accessorKey: "ip",
|
||||
header: "IP地址",
|
||||
},
|
||||
{
|
||||
accessorKey: "createTime",
|
||||
header: "设置时间",
|
||||
},
|
||||
{
|
||||
accessorKey: "isLocked",
|
||||
header: "锁定状态",
|
||||
cell: ({ row }) => {
|
||||
const isLocked = row.original.isLocked === "已锁定"
|
||||
return (
|
||||
<Badge variant={isLocked ? "destructive" : "outline"}>
|
||||
{row.original.isLocked}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "操作",
|
||||
cell: ({ row }) => {
|
||||
const item = row.original
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleLock(item)}
|
||||
>
|
||||
{item.isLocked === "未锁定" ? (
|
||||
<Lock className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<Unlock className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{item.isLocked === "未锁定" ? "锁定" : "解锁"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => removeIp(item)}
|
||||
>
|
||||
<Trash2 className="mr-1 h-3 w-3" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</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>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
调整后到期时间:{newExpire.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button onClick={confirmSetLimit}>确定</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Page>
|
||||
)
|
||||
}
|
||||
@@ -70,6 +70,7 @@ async function request<R = undefined>(
|
||||
Message?: string
|
||||
Data?: unknown
|
||||
data?: unknown
|
||||
TotalCount?: number
|
||||
}
|
||||
if (!response.ok) {
|
||||
return {
|
||||
@@ -85,6 +86,8 @@ async function request<R = undefined>(
|
||||
success: true,
|
||||
data: (json.Data ?? json.data ?? undefined) as R,
|
||||
headers: responseHeaders,
|
||||
totalCount:
|
||||
typeof json.TotalCount === "number" ? json.TotalCount : undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
@@ -168,7 +171,28 @@ async function call<R = undefined>(
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
const body = data === undefined ? undefined : JSON.stringify(data)
|
||||
// GET 请求:将 data 序列化为查询串;POST 请求:序列化为 JSON body
|
||||
let url = endpoint
|
||||
let body: string | undefined
|
||||
if (method === "GET") {
|
||||
const params = new URLSearchParams()
|
||||
if (data && typeof data === "object") {
|
||||
for (const [key, value] of Object.entries(
|
||||
data as Record<string, unknown>,
|
||||
)) {
|
||||
if (value === undefined || value === null || value === "") continue
|
||||
if (Array.isArray(value)) {
|
||||
params.append(key, value.join(","))
|
||||
} else {
|
||||
params.append(key, String(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
const query = params.toString()
|
||||
if (query) url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}${query}`
|
||||
} else {
|
||||
body = data === undefined ? undefined : JSON.stringify(data)
|
||||
}
|
||||
|
||||
const run = async (): Promise<ApiResponse<R>> => {
|
||||
const maxAttempts = mode === "device" ? 2 : 1
|
||||
@@ -202,7 +226,7 @@ async function call<R = undefined>(
|
||||
auth = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const resp = await request<R>(endpoint, { method, body, auth })
|
||||
const resp = await request<R>(url, { method, body, auth })
|
||||
|
||||
if (!resp.success && resp.status === 401) {
|
||||
if (mode === "user") {
|
||||
|
||||
25
src/api/cashOut.ts
Normal file
25
src/api/cashOut.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { CashOutItem } from "@/lib/models/cashOut"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
// ==================== 提现管理 ====================
|
||||
// TODO: 接口路径待后端确认(旧后台为 php-api: /order/CashOutAdmin/*)。
|
||||
|
||||
export async function fetchCashOutList(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<CashOutItem[]>> {
|
||||
return await callByUser<CashOutItem[]>(
|
||||
"/api/course/v1/order/CashOutList",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function handleCashOut(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>(
|
||||
"/api/course/v1/order/CashOutProcess",
|
||||
data,
|
||||
)
|
||||
}
|
||||
15
src/api/channel.ts
Normal file
15
src/api/channel.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { ChannelRecord } from "@/lib/models/channel"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
// ==================== 通道/账号列表(.NET /api/course/v1/productaccount/*) ====================
|
||||
|
||||
export async function fetchChannelPage(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<ChannelRecord[]>> {
|
||||
return await callByUser<ChannelRecord[]>(
|
||||
"/api/course/v1/productaccount/Page",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
35
src/api/coupon.ts
Normal file
35
src/api/coupon.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { CouponItem, CouponQueryParams } from "@/lib/models/coupon"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
// ==================== 优惠券(.NET /api/sells/v1/coupon/*) ====================
|
||||
|
||||
export async function fetchCouponPage(
|
||||
params: CouponQueryParams,
|
||||
): Promise<ApiResponse<CouponItem[]>> {
|
||||
return await callByUser<CouponItem[]>(
|
||||
"/api/sells/v1/coupon/Page",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function disabledCoupon(
|
||||
id: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>("/api/sells/v1/coupon/Disabled", {
|
||||
Id: id,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteCoupon(
|
||||
id: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>("/api/sells/v1/coupon/Delete", { id })
|
||||
}
|
||||
|
||||
export async function giveCoupon(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>("/api/sells/v1/coupon/Give", data)
|
||||
}
|
||||
52
src/api/dashboard.ts
Normal file
52
src/api/dashboard.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { OrderItem, OrderQueryParams } from "@/lib/models/order"
|
||||
import { callByUser } from "./base"
|
||||
import { fetchHttpBalance } from "./http"
|
||||
|
||||
// ==================== 仪表盘 ====================
|
||||
// 余额走 php-api,订单/消费统计走 .NET;账号信息与 IP 统计接口待后端确认。
|
||||
|
||||
export async function fetchDashboardBalance(): Promise<ApiResponse<number>> {
|
||||
return fetchHttpBalance()
|
||||
}
|
||||
|
||||
export async function fetchDashboardConsumption(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/course/v1/order/UserConsumeStatistics",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchDashboardRecentOrders(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<OrderItem[]>> {
|
||||
return await callByUser<OrderItem[]>(
|
||||
"/api/course/v1/order/Page",
|
||||
{ ...params, PageIndex: 1, PageSize: 10 },
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: 账号信息/IP 统计接口路径待后端确认
|
||||
export async function fetchDashboardAccountInfo(): Promise<
|
||||
ApiResponse<Record<string, unknown>>
|
||||
> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/baseinfo/v1/user/Info",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchDashboardIpStats(): Promise<
|
||||
ApiResponse<Record<string, unknown>>
|
||||
> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/course/v1/order/IpAccountStatistics",
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
281
src/api/http.ts
281
src/api/http.ts
@@ -6,6 +6,22 @@ import type {
|
||||
GameOrderData,
|
||||
HttpOrderInfo,
|
||||
} from "@/lib/models/http"
|
||||
import type {
|
||||
ChangeIpData,
|
||||
CxOrder,
|
||||
CxPackage,
|
||||
DxOrder,
|
||||
DxPackage,
|
||||
LangIpExtract,
|
||||
LangIpItem,
|
||||
LangIpListResult,
|
||||
OrderActionResult,
|
||||
PackageOrderInfo,
|
||||
Pager,
|
||||
SwitchListResult,
|
||||
SwitchRecord,
|
||||
UserApiToken,
|
||||
} from "@/lib/models/package"
|
||||
|
||||
export const PHP_API_BASE_URL =
|
||||
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
|
||||
@@ -14,18 +30,27 @@ const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
type RawHttpResponse = Record<string, unknown>
|
||||
|
||||
async function postJson<T = RawHttpResponse>(
|
||||
// ==================== 统一 PHP 请求底层 ====================
|
||||
// 所有 /http/* 接口都带 cookie 走同一出口:超时、错误归一化在此层处理,
|
||||
// 各接口函数只关心业务数据,不再各自实现 fetch。
|
||||
// credentials 仅对需要登录态的 POST 开启;公开脚本 GET 接口(如线路表)
|
||||
// 不带 cookie,避免跨域时服务端 Access-Control-Allow-Origin 配置不匹配。
|
||||
|
||||
export async function phpFetch<T = RawHttpResponse>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body: unknown,
|
||||
body?: unknown,
|
||||
options: { credentials?: RequestCredentials } = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { credentials = "same-origin" } = options
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
try {
|
||||
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
method,
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
credentials,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
@@ -55,7 +80,25 @@ async function postJson<T = RawHttpResponse>(
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
|
||||
export async function phpPost<T = RawHttpResponse>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<ApiResponse<T>> {
|
||||
return phpFetch<T>("POST", path, body, { credentials: "include" })
|
||||
}
|
||||
|
||||
export async function phpGet<T = RawHttpResponse>(
|
||||
path: string,
|
||||
): Promise<ApiResponse<T>> {
|
||||
return phpFetch<T>("GET", path)
|
||||
}
|
||||
|
||||
// ==================== 响应解析工具 ====================
|
||||
|
||||
// 老 PHP 接口习惯把业务数据包在 d 字段里
|
||||
export function unwrapD(
|
||||
raw: RawHttpResponse | undefined,
|
||||
): Record<string, unknown> {
|
||||
const d = raw?.d
|
||||
if (d && typeof d === "object" && !Array.isArray(d)) {
|
||||
return d as Record<string, unknown>
|
||||
@@ -63,43 +106,71 @@ function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
|
||||
return {}
|
||||
}
|
||||
|
||||
export function isArray<T>(value: unknown): value is T[] {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown): number {
|
||||
const n = Number(value)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
// 解析服务端分页信息,缺失字段兜底
|
||||
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),
|
||||
totalPage: toNumber(p.totalPage),
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 游戏产品 ====================
|
||||
|
||||
export async function fetchGameCities(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameCity[]>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/city", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/city", game)
|
||||
if (!res.success) return res
|
||||
const cities = unwrapD(res.data).cities
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(cities) ? (cities as GameCity[]) : [],
|
||||
data: isArray<GameCity>(cities) ? cities : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGameList(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameItem[]>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/game", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/game", game)
|
||||
if (!res.success) return res
|
||||
const games = unwrapD(res.data).games
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(games) ? (games as GameItem[]) : [],
|
||||
data: isArray<GameItem>(games) ? games : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchGameLineCount(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/product/linecount", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/linecount", game)
|
||||
if (!res.success) return res
|
||||
const count = unwrapD(res.data).count
|
||||
return { success: true, data: Number(count) || 0 }
|
||||
return { success: true, data: toNumber(count) }
|
||||
}
|
||||
|
||||
export async function calcGamePrice(
|
||||
orderInfo: HttpOrderInfo,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse>(
|
||||
return calcOrderPrice(orderInfo as unknown as PackageOrderInfo)
|
||||
}
|
||||
|
||||
export async function calcOrderPrice(
|
||||
orderInfo: PackageOrderInfo,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
orderInfo,
|
||||
)
|
||||
@@ -113,10 +184,12 @@ export async function calcGamePrice(
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 订单 ====================
|
||||
|
||||
export async function createHttpOrder(
|
||||
orderInfo: HttpOrderInfo,
|
||||
): Promise<ApiResponse<CreateOrderResult>> {
|
||||
const res = await postJson<RawHttpResponse>("/http/order/create_order", {
|
||||
const res = await phpPost<RawHttpResponse>("/http/order/create_order", {
|
||||
cookie: document.cookie,
|
||||
order_info: orderInfo,
|
||||
})
|
||||
@@ -124,23 +197,187 @@ export async function createHttpOrder(
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
code: Number(res.data?.code) || 0,
|
||||
code: toNumber(res.data?.code),
|
||||
msg: typeof res.data?.msg === "string" ? res.data.msg : undefined,
|
||||
data: typeof res.data?.data === "string" ? res.data.data : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHttpBalance(): Promise<ApiResponse<number>> {
|
||||
const res = await postJson<RawHttpResponse | number>(
|
||||
"/http/user/get_balance",
|
||||
{
|
||||
function parseOrderAction(raw: RawHttpResponse): OrderActionResult {
|
||||
return {
|
||||
code: toNumber(raw.code),
|
||||
msg: typeof raw.msg === "string" ? raw.msg : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function rebuyOrder(
|
||||
orderInfo: PackageOrderInfo,
|
||||
): Promise<ApiResponse<OrderActionResult>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/order/rebuy_order", {
|
||||
cookie: document.cookie,
|
||||
},
|
||||
order_info: { ...orderInfo, renew: 1 },
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: parseOrderAction(res.data ?? {}) }
|
||||
}
|
||||
|
||||
export async function changeIpOrder(
|
||||
orderInfo: Omit<PackageOrderInfo, "data"> & { data: ChangeIpData },
|
||||
): Promise<ApiResponse<OrderActionResult>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/order/change_ip_order", {
|
||||
cookie: document.cookie,
|
||||
order_info: orderInfo,
|
||||
})
|
||||
if (!res.success) return res
|
||||
return { success: true, data: parseOrderAction(res.data ?? {}) }
|
||||
}
|
||||
|
||||
// ==================== 用户信息 ====================
|
||||
|
||||
export async function fetchHttpBalance(): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse | number>(
|
||||
"/http/user/get_balance",
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
if (typeof res.data === "number") return { success: true, data: res.data }
|
||||
const raw = res.data ?? {}
|
||||
const balance = raw.d ?? raw.data ?? raw.balance
|
||||
return { success: true, data: Number(balance) || 0 }
|
||||
return { success: true, data: toNumber(balance) }
|
||||
}
|
||||
|
||||
export async function fetchUserApiToken(): Promise<ApiResponse<UserApiToken>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/get_user_token", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = (res.data?.data ?? res.data ?? {}) as Record<string, unknown>
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uid: raw.uid as UserApiToken["uid"],
|
||||
token: typeof raw.token === "string" ? raw.token : "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchWhiteLimitMax(): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/white_limit_get_su", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const raw = (res.data?.data ?? res.data ?? {}) as Record<string, unknown>
|
||||
// 返回值可能是 { su: n } / { data: n },也可能直接是数字,逐层尝试
|
||||
return { success: true, data: toNumber(raw.su ?? raw.data ?? raw) }
|
||||
}
|
||||
|
||||
// ==================== 短效套餐 ====================
|
||||
|
||||
export async function fetchDxPackages(): Promise<ApiResponse<DxPackage[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/dx_package", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<DxPackage>(list) ? list : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDxOrders(): Promise<ApiResponse<DxOrder[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/dx_order", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return { success: true, data: isArray<DxOrder>(list) ? list : [] }
|
||||
}
|
||||
|
||||
// ==================== 长效套餐 ====================
|
||||
|
||||
export async function fetchCxPackages(): Promise<ApiResponse<CxPackage[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/cx_package", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<CxPackage>(list) ? list : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCxOrders(): Promise<ApiResponse<CxOrder[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/cx_order", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const list = res.data?.data ?? res.data
|
||||
return { success: true, data: isArray<CxOrder>(list) ? list : [] }
|
||||
}
|
||||
|
||||
// ==================== 长效 IP 列表/提取/切换 ====================
|
||||
|
||||
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,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const items = isArray<LangIpItem>(d.items) ? d.items : []
|
||||
return {
|
||||
success: true,
|
||||
data: { items, pager: parsePager(d.pager, items.length) },
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const items = isArray<SwitchRecord>(d.items) ? d.items : []
|
||||
return {
|
||||
success: true,
|
||||
data: { items, pager: parsePager(d.pager, items.length) },
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLangIps(
|
||||
ids: string,
|
||||
): Promise<ApiResponse<LangIpExtract[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/get_lang_ip", {
|
||||
cookie: document.cookie,
|
||||
data: { ids },
|
||||
})
|
||||
if (!res.success) return res
|
||||
const ips = unwrapD(res.data).ips
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<LangIpExtract>(ips) ? ips : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchSwitchCities(
|
||||
data: Record<string, unknown>,
|
||||
switchPop: 0 | 1,
|
||||
): Promise<ApiResponse<GameCity[]>> {
|
||||
const endpoint =
|
||||
switchPop === 1 ? "/http/product/in_city" : "/http/product/out_city"
|
||||
const res = await phpPost<RawHttpResponse>(endpoint, data)
|
||||
if (!res.success) return res
|
||||
const cities = unwrapD(res.data).cities
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<GameCity>(cities) ? cities : [],
|
||||
}
|
||||
}
|
||||
|
||||
16
src/api/httpRecharge.ts
Normal file
16
src/api/httpRecharge.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
// ==================== 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",
|
||||
)
|
||||
}
|
||||
@@ -1,65 +1,9 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
|
||||
export const PHP_API_BASE_URL =
|
||||
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
export type LineItem = {
|
||||
city: string | number
|
||||
daikuan: string | number
|
||||
name: string | number
|
||||
nasname: string | number
|
||||
online: string | number
|
||||
supply: string | number
|
||||
}
|
||||
|
||||
export type LineData = {
|
||||
count: number | string
|
||||
use_count: number | string
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
export type LineSearchResult = {
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: "请求失败",
|
||||
}
|
||||
}
|
||||
const data = (await response.json()) as T
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
status: 408,
|
||||
message: "请求超时,请稍后重试",
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: 500,
|
||||
message: (e as Error).message || "网络错误",
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
import type { LineData, LineSearchResult } from "@/lib/models/line"
|
||||
import { phpGet } from "./http"
|
||||
|
||||
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
|
||||
return getJson<LineData>(
|
||||
`${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`,
|
||||
)
|
||||
return phpGet<LineData>(`/script/linedata/display.php?product=${product}`)
|
||||
}
|
||||
|
||||
export function searchLineData(
|
||||
@@ -67,7 +11,5 @@ export function searchLineData(
|
||||
info: string,
|
||||
): Promise<ApiResponse<LineSearchResult>> {
|
||||
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
||||
return getJson<LineSearchResult>(
|
||||
`${PHP_API_BASE_URL}/script/linedata/search.php?${query}`,
|
||||
)
|
||||
return phpGet<LineSearchResult>(`/script/linedata/search.php?${query}`)
|
||||
}
|
||||
|
||||
109
src/api/order.ts
109
src/api/order.ts
@@ -1,39 +1,14 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
CreateOrderData,
|
||||
CreateOrderRequest,
|
||||
CreateTestAccountRequest,
|
||||
OrderItem,
|
||||
OrderQueryParams,
|
||||
} from "@/lib/models/order"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
export type CreateOrderRequest = {
|
||||
PackageId: number
|
||||
OrderType: number
|
||||
Account: string
|
||||
Pwd: string
|
||||
ConnectCount: number
|
||||
MinPostfix?: number
|
||||
MaxPostfix?: number
|
||||
CouponId?: number
|
||||
UseAccountAmount?: number
|
||||
OPayType: number
|
||||
PayChannel?: number
|
||||
Price?: number
|
||||
}
|
||||
|
||||
export type CreateTestAccountRequest = {
|
||||
ProductId: number
|
||||
PackageId: number
|
||||
Account: string
|
||||
Pwd: string
|
||||
}
|
||||
|
||||
export type OrderInfo = {
|
||||
OrderNo: string
|
||||
OtherPayAmount: number
|
||||
CreateTime: string
|
||||
PayType: number
|
||||
}
|
||||
|
||||
export type CreateOrderData = {
|
||||
OrderInfo: OrderInfo
|
||||
PayData: string
|
||||
}
|
||||
// ==================== 购买下单(php-api /product/*) ====================
|
||||
|
||||
export async function createOrder(
|
||||
data: CreateOrderRequest,
|
||||
@@ -62,3 +37,71 @@ export async function checkPayStatus(
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 管理端订单(.NET /api/course/v1/order/*) ====================
|
||||
|
||||
// 订单分页列表
|
||||
export async function fetchOrderPage(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<OrderItem[]>> {
|
||||
return await callByUser<OrderItem[]>(
|
||||
"/api/course/v1/order/Page",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 退款订单分页列表
|
||||
export async function fetchRefundOrders(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<OrderItem[]>> {
|
||||
return await callByUser<OrderItem[]>(
|
||||
"/api/course/v1/order/RefundOrders",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 优惠券订单分页列表
|
||||
export async function fetchCouponOrders(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<OrderItem[]>> {
|
||||
return await callByUser<OrderItem[]>(
|
||||
"/api/course/v1/order/CouponOrders",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 订单统计(Statistics 接口返回统计结构,待后端确认字段后收窄类型)
|
||||
export async function fetchOrderStatistics(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/course/v1/order/Statistics",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 用户消费统计
|
||||
export async function fetchUserConsumeStatistics(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/course/v1/order/UserConsumeStatistics",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
// 订单导出
|
||||
export async function exportOrders(
|
||||
params: OrderQueryParams,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>(
|
||||
"/api/course/v1/order/Export",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
43
src/api/record.ts
Normal file
43
src/api/record.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
HttpUsedLogItem,
|
||||
HttpUsedLogQuery,
|
||||
HttpUsedSummary,
|
||||
} from "@/lib/models/record"
|
||||
import { isArray, 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", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
todayShortPackIpUsed: toNumber(d.todayShortPackIpUsed),
|
||||
shortPackIpUsed: toNumber(d.shortPackIpUsed),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchHttpUsedLogs(
|
||||
query: HttpUsedLogQuery,
|
||||
): Promise<ApiResponse<HttpUsedLogItem[]>> {
|
||||
const res = await phpPost("/http/user/pack_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
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<HttpUsedLogItem>(list) ? list : [],
|
||||
}
|
||||
}
|
||||
16
src/api/rosorder.ts
Normal file
16
src/api/rosorder.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { RosOrderItem } from "@/lib/models/rosorder"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
// ==================== 软路由订单 ====================
|
||||
// TODO: 接口路径待后端确认(旧后台为 /course/v1/order/ros 类接口)。
|
||||
|
||||
export async function fetchRosOrderList(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<RosOrderItem[]>> {
|
||||
return await callByUser<RosOrderItem[]>(
|
||||
"/api/course/v1/order/RosOrders",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
44
src/api/user.ts
Normal file
44
src/api/user.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { UserItem, UserQueryParams } from "@/lib/models/user"
|
||||
import { callByUser } from "./base"
|
||||
|
||||
// ==================== 用户管理(.NET /api/baseinfo/v1/user/*) ====================
|
||||
|
||||
export async function fetchUserPage(
|
||||
params: UserQueryParams,
|
||||
): Promise<ApiResponse<UserItem[]>> {
|
||||
return await callByUser<UserItem[]>(
|
||||
"/api/baseinfo/v1/user/Page",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchUserSearch(
|
||||
params: UserQueryParams,
|
||||
): Promise<ApiResponse<UserItem[]>> {
|
||||
return await callByUser<UserItem[]>(
|
||||
"/api/baseinfo/v1/user/Search",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function fetchAmountDetails(
|
||||
params: Record<string, unknown>,
|
||||
): Promise<ApiResponse<Record<string, unknown>>> {
|
||||
return await callByUser<Record<string, unknown>>(
|
||||
"/api/baseinfo/v1/user/AmountDetails",
|
||||
params,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function updateUserProfile(
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callByUser<undefined>(
|
||||
"/api/baseinfo/v1/user/UpdateProfile",
|
||||
data,
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { SMSPurpose } from "@/lib/models/auth"
|
||||
import { callPublic } from "./base"
|
||||
|
||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
||||
|
||||
export async function sendSMS(
|
||||
phone: string,
|
||||
purpose: SMSPurpose = "User_Code",
|
||||
|
||||
56
src/api/whitelist.ts
Normal file
56
src/api/whitelist.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { WhiteIpItem } from "@/lib/models/whitelist"
|
||||
import { isArray, phpPost, unwrapD } from "./http"
|
||||
|
||||
// ==================== 白名单(php-api /http/user/*) ====================
|
||||
// TODO: 后端接口路径待确认,以下路径按现有 /http/user/white_* 命名规律推测,
|
||||
// 后端就绪后需按实际路径修正。
|
||||
|
||||
export async function fetchWhiteList(): Promise<ApiResponse<WhiteIpItem[]>> {
|
||||
const res = await phpPost("/http/user/white_list", {
|
||||
cookie: document.cookie,
|
||||
})
|
||||
if (!res.success) return res
|
||||
const d = unwrapD(res.data)
|
||||
const list = d.list ?? d.data ?? res.data?.data
|
||||
return {
|
||||
success: true,
|
||||
data: isArray<WhiteIpItem>(list) ? list : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveWhiteIp(ip: string): Promise<ApiResponse<undefined>> {
|
||||
return await phpPost("/http/user/white_save", {
|
||||
cookie: document.cookie,
|
||||
ip,
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteWhiteIp(
|
||||
id: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await phpPost("/http/user/white_delete", {
|
||||
cookie: document.cookie,
|
||||
id,
|
||||
})
|
||||
}
|
||||
|
||||
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 async function setWhiteLimitMax(
|
||||
limit: number,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await phpPost("/http/user/white_limit_set_su", {
|
||||
cookie: document.cookie,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
21
src/components/composites/accountCell.tsx
Normal file
21
src/components/composites/accountCell.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cutAccount } from "@/lib/models/order"
|
||||
|
||||
// 订单账号单元格:过长时截断并用 Tooltip 展示完整内容
|
||||
export default function AccountCell({ accounts }: { accounts: string }) {
|
||||
const isCut = accounts.length > 15
|
||||
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +1,18 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import {
|
||||
fetchLineData,
|
||||
type LineItem,
|
||||
PHP_API_BASE_URL,
|
||||
searchLineData,
|
||||
} from "@/api/linedata"
|
||||
import { PHP_API_BASE_URL } from "@/api/http"
|
||||
import { fetchLineData, searchLineData } from "@/api/linedata"
|
||||
import DataTable from "@/components/data-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { LineItem } from "@/lib/models/line"
|
||||
import { PRODUCTS } from "@/lib/models/line"
|
||||
import { cn, downloadCsv } from "@/lib/utils"
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000
|
||||
const PAGE_SIZE_OPTIONS = [100, 200, 500]
|
||||
|
||||
type ProductOption = {
|
||||
id: number
|
||||
name: string
|
||||
l2tp: string
|
||||
sstp: string
|
||||
}
|
||||
|
||||
const PRODUCTS: ProductOption[] = [
|
||||
{ id: 29, name: "极狐IP", l2tp: "8899", sstp: "4430" },
|
||||
{ id: 3, name: "极光IP", l2tp: "1234", sstp: "4430" },
|
||||
// { id: 18, name: "蘑菇IP", l2tp: "8899", sstp: "4430" },
|
||||
{ id: 27, name: "麒麟IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 26, name: "猎豹IP", l2tp: "1234", sstp: "4430" },
|
||||
{ id: 28, name: "水滴独享IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 22, name: "火狐静态IP", l2tp: "888888", sstp: "5908" },
|
||||
]
|
||||
|
||||
function toText(value: string | number): string {
|
||||
return value === null || value === undefined ? "" : String(value)
|
||||
}
|
||||
@@ -120,7 +101,6 @@ export default function IpLinePage() {
|
||||
setPageIndex(0)
|
||||
const seq = ++requestSeq.current
|
||||
const result = await searchLineData(productId ?? 0, info)
|
||||
console.log("搜索的result", result)
|
||||
|
||||
if (seq !== requestSeq.current) return
|
||||
if (result.success) {
|
||||
@@ -154,29 +134,15 @@ export default function IpLinePage() {
|
||||
"带宽",
|
||||
"服务器状态",
|
||||
]
|
||||
const lines = [
|
||||
header,
|
||||
...rows.map(r => [
|
||||
const data = rows.map(r => [
|
||||
toText(r.name),
|
||||
toText(r.city),
|
||||
toText(r.supply),
|
||||
toText(r.nasname),
|
||||
toText(r.daikuan),
|
||||
toText(r.online),
|
||||
]),
|
||||
]
|
||||
const csv = lines
|
||||
.map(line => line.map(v => `"${v.replaceAll('"', '""')}"`).join(","))
|
||||
.join("\r\n")
|
||||
const blob = new Blob([`\uFEFF${csv}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${currentName || "IP线路表"}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
])
|
||||
downloadCsv(`${currentName || "IP线路表"}.csv`, header, data)
|
||||
toast.success("导出成功")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { type CreateOrderRequest, createOrder } from "@/api/order"
|
||||
import { createOrder } from "@/api/order"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import Wrap from "@/components/wrap"
|
||||
import type { CreateOrderRequest } from "@/lib/models/order"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import { cn } from "@/lib/utils"
|
||||
import alipay from "../_assets/alipay.svg"
|
||||
|
||||
@@ -80,7 +80,7 @@ function OperatorSelect({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{operators.map((op) => (
|
||||
{operators.map(op => (
|
||||
<button
|
||||
key={op}
|
||||
className={cn(
|
||||
@@ -129,7 +129,7 @@ function RegionGrid({
|
||||
H: "H",
|
||||
I: "I",
|
||||
}
|
||||
regions.forEach((r) => {
|
||||
regions.forEach(r => {
|
||||
const key = letterMap[r.id] || "其他"
|
||||
if (!groupedRegions[key]) groupedRegions[key] = []
|
||||
groupedRegions[key].push(r)
|
||||
@@ -143,7 +143,7 @@ function RegionGrid({
|
||||
{letter}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2 flex-1">
|
||||
{items.map((region) => (
|
||||
{items.map(region => (
|
||||
<button
|
||||
key={region.id}
|
||||
className={cn(
|
||||
@@ -155,7 +155,9 @@ function RegionGrid({
|
||||
onClick={() => onSelect(region.name)}
|
||||
>
|
||||
{region.name}
|
||||
<span className="ml-1 text-xs text-gray-400">余量:{region.stock}</span>
|
||||
<span className="ml-1 text-xs text-gray-400">
|
||||
余量:{region.stock}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -177,7 +179,7 @@ function BandwidthSelect({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((bw) => (
|
||||
{options.map(bw => (
|
||||
<button
|
||||
key={bw}
|
||||
className={cn(
|
||||
@@ -211,7 +213,7 @@ function DurationSelect({
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((opt) => (
|
||||
{options.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn(
|
||||
@@ -242,10 +244,10 @@ function ProjectSelect({
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="h-10 px-3 rounded-xl border border-gray-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 w-full max-w-xs"
|
||||
>
|
||||
{projects.map((p) => (
|
||||
{projects.map(p => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
@@ -281,7 +283,12 @@ export default function BuyStaticPage() {
|
||||
|
||||
// 根据类型计算价格
|
||||
const getPrice = () => {
|
||||
const bwMap: Record<string, number> = { "2M": 10, "4M": 20, "10M": 40, "20M": 80 }
|
||||
const bwMap: Record<string, number> = {
|
||||
"2M": 10,
|
||||
"4M": 20,
|
||||
"10M": 40,
|
||||
"20M": 80,
|
||||
}
|
||||
const durationMap: Record<string, number> = { test: 0.1, day: 1, month: 10 }
|
||||
const basePrice = bwMap[bandwidth] || 10
|
||||
const durationPrice = durationMap[duration] || 0.1
|
||||
@@ -315,7 +322,9 @@ export default function BuyStaticPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-gray-600">专线独享:个人独享整个线路,真实独享,永远不重复。</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
专线独享:个人独享整个线路,真实独享,永远不重复。
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-red-500">线路切换单日限制为20次</p>
|
||||
<p className="mt-1 text-sm text-red-500">
|
||||
如果没有自己想用的地区线路或线路余量不足,请联系客服上架
|
||||
@@ -356,7 +365,7 @@ export default function BuyStaticPage() {
|
||||
placeholder="请输入地区"
|
||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||
value={regionSearch}
|
||||
onChange={(e) => setRegionSearch(e.target.value)}
|
||||
onChange={e => setRegionSearch(e.target.value)}
|
||||
/>
|
||||
<Button className="h-10 rounded-xl px-6 bg-blue-500 hover:bg-blue-600 text-white">
|
||||
搜索
|
||||
@@ -438,7 +447,9 @@ export default function BuyStaticPage() {
|
||||
<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">¥{getPrice()}</span>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{getPrice()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 余额 */}
|
||||
|
||||
@@ -51,7 +51,6 @@ export default function ProductPage() {
|
||||
const results = await Promise.allSettled(
|
||||
TAB_SOURCES.map(source => source.fetch()),
|
||||
)
|
||||
console.log(results, "resultsresultsresults")
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ export default function PurchaseInfo() {
|
||||
tabIndex={0}
|
||||
className="w-full overflow-hidden rounded-lg border border-gray-200 shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setIsImageOpen(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
setIsImageOpen(true)
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export default function PurchaseInfo() {
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-4 right-4 text-white bg-black/50 rounded-full w-10 h-10 text-2xl hover:bg-black/70 transition-colors pointer-events-auto"
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
setIsImageOpen(false)
|
||||
}}
|
||||
|
||||
@@ -59,7 +59,10 @@ export default function ShuidiPools({ onBuy }: ShuidiPoolsProps) {
|
||||
|
||||
<div className="text-sm text-gray-500 space-y-1.5 mt-5">
|
||||
{pool.features.map((feature, index) => (
|
||||
<div key={index} className="flex items-center justify-center gap-2 text-amber-500">
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-center gap-2 text-amber-500"
|
||||
>
|
||||
{feature}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -19,6 +19,20 @@ type ApiResponse<T = undefined> =
|
||||
data: T
|
||||
// 响应头(key 统一小写),用于登录等场景从 header 取数据
|
||||
headers?: Record<string, string>
|
||||
// .NET 分页接口(ApiResultPaged)携带的总条数
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
|
||||
// 服务端分页结果
|
||||
type PageResult<T> = {
|
||||
total: number
|
||||
list: T[]
|
||||
}
|
||||
|
||||
export {
|
||||
API_BASE_URL,
|
||||
type ApiResponse,
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
type PageResult,
|
||||
}
|
||||
|
||||
51
src/lib/hooks/useOrderFilters.ts
Normal file
51
src/lib/hooks/useOrderFilters.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
DEFAULT_FILTERS,
|
||||
matchOrders,
|
||||
type OrderFilters,
|
||||
type OrderItem,
|
||||
} from "@/lib/models/order"
|
||||
|
||||
// 订单列表通用筛选/分页逻辑(orders / refundOrders 页面共用)
|
||||
export function useOrderFilters(list: OrderItem[]) {
|
||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [appliedFilters, setAppliedFilters] =
|
||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
|
||||
const filtered = useMemo(
|
||||
() => matchOrders(list, appliedFilters),
|
||||
[list, appliedFilters],
|
||||
)
|
||||
|
||||
const paged = useMemo(
|
||||
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||
[filtered, page, pageSize],
|
||||
)
|
||||
|
||||
const patch = (partial: Partial<OrderFilters>) =>
|
||||
setFilters({ ...filters, ...partial })
|
||||
|
||||
const handleSearch = () => {
|
||||
setAppliedFilters(filters)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
setPageSize(size)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return {
|
||||
filters,
|
||||
patch,
|
||||
handleSearch,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
handleSizeChange,
|
||||
filtered,
|
||||
paged,
|
||||
}
|
||||
}
|
||||
2
src/lib/models/auth.ts
Normal file
2
src/lib/models/auth.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// 短信验证码用途
|
||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
||||
10
src/lib/models/cashOut.ts
Normal file
10
src/lib/models/cashOut.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type CashOutItem = {
|
||||
id: number
|
||||
username: string
|
||||
money: number
|
||||
reason: string
|
||||
alipay_account: string
|
||||
remark: string
|
||||
status: string
|
||||
create_time: string
|
||||
}
|
||||
28
src/lib/models/coupon.ts
Normal file
28
src/lib/models/coupon.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// 优惠券(.NET /api/sells/v1/coupon/*,对应 CouponEntity)
|
||||
export type CouponItem = {
|
||||
Id: number
|
||||
Name: string
|
||||
Remark: string
|
||||
CouponType: number
|
||||
TotalCount?: number
|
||||
GrantCount?: number
|
||||
UsedCount?: number
|
||||
GetLimitCount?: number
|
||||
UseRange: number
|
||||
AllowMinAmount: number
|
||||
CouponValue: number
|
||||
DateRule: number
|
||||
ValidDay: number
|
||||
StartDate: string
|
||||
EndDate: string
|
||||
CreateDate: string
|
||||
IsOverlay: number
|
||||
IsOpen: number
|
||||
Disabled?: number
|
||||
}
|
||||
|
||||
export type CouponQueryParams = {
|
||||
PageIndex?: number
|
||||
PageSize?: number
|
||||
KeyWord?: string
|
||||
}
|
||||
40
src/lib/models/dashboard.ts
Normal file
40
src/lib/models/dashboard.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type DashboardBalance = {
|
||||
balance: number
|
||||
hCoin: number
|
||||
}
|
||||
|
||||
export type AccountInfo = {
|
||||
phone: string
|
||||
qq: string
|
||||
wechat: string
|
||||
email: string
|
||||
taobao: string
|
||||
}
|
||||
|
||||
export type RecentOrderItem = {
|
||||
date: string
|
||||
product: string
|
||||
plan: string
|
||||
price: number
|
||||
connections: number
|
||||
account: string
|
||||
orderAmount: number
|
||||
paidAmount: number
|
||||
}
|
||||
|
||||
export type ConsumptionStats = {
|
||||
today: number
|
||||
month: number
|
||||
todayRefund: number
|
||||
year: number
|
||||
todayRecharge: number
|
||||
monthRecharge: number
|
||||
monthRefund: number
|
||||
}
|
||||
|
||||
export type IpAccountStats = {
|
||||
using: number
|
||||
total: number
|
||||
expired: number
|
||||
yearConsumption: number
|
||||
}
|
||||
6
src/lib/models/httpRecharge.ts
Normal file
6
src/lib/models/httpRecharge.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type HttpRechargeItem = {
|
||||
create_time: string
|
||||
ju_money: number
|
||||
pay_type: string
|
||||
pay_money: number
|
||||
}
|
||||
35
src/lib/models/line.ts
Normal file
35
src/lib/models/line.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type LineItem = {
|
||||
city: string | number
|
||||
daikuan: string | number
|
||||
name: string | number
|
||||
nasname: string | number
|
||||
online: string | number
|
||||
supply: string | number
|
||||
}
|
||||
|
||||
export type LineData = {
|
||||
count: number | string
|
||||
use_count: number | string
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
export type LineSearchResult = {
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
// IP线路表产品选项
|
||||
export type ProductOption = {
|
||||
id: number
|
||||
name: string
|
||||
l2tp: string
|
||||
sstp: string
|
||||
}
|
||||
|
||||
export const PRODUCTS: ProductOption[] = [
|
||||
{ id: 29, name: "极狐IP", l2tp: "8899", sstp: "4430" },
|
||||
{ id: 3, name: "极光IP", l2tp: "1234", sstp: "4430" },
|
||||
{ id: 27, name: "麒麟IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 26, name: "猎豹IP", l2tp: "1234", sstp: "4430" },
|
||||
{ id: 28, name: "水滴独享IP", l2tp: "123", sstp: "4432" },
|
||||
{ id: 22, name: "火狐静态IP", l2tp: "888888", sstp: "5908" },
|
||||
]
|
||||
@@ -16,6 +16,58 @@ export type OrderItem = {
|
||||
UpdateTime: string
|
||||
}
|
||||
|
||||
// ==================== 下单接口(php-api /product/CreateOrder) ====================
|
||||
|
||||
export type CreateOrderRequest = {
|
||||
PackageId: number
|
||||
OrderType: number
|
||||
Account: string
|
||||
Pwd: string
|
||||
ConnectCount: number
|
||||
MinPostfix?: number
|
||||
MaxPostfix?: number
|
||||
CouponId?: number
|
||||
UseAccountAmount?: number
|
||||
OPayType: number
|
||||
PayChannel?: number
|
||||
Price?: number
|
||||
}
|
||||
|
||||
export type CreateTestAccountRequest = {
|
||||
ProductId: number
|
||||
PackageId: number
|
||||
Account: string
|
||||
Pwd: string
|
||||
}
|
||||
|
||||
export type OrderInfo = {
|
||||
OrderNo: string
|
||||
OtherPayAmount: number
|
||||
CreateTime: string
|
||||
PayType: number
|
||||
}
|
||||
|
||||
export type CreateOrderData = {
|
||||
OrderInfo: OrderInfo
|
||||
PayData: string
|
||||
}
|
||||
|
||||
// ==================== 管理端订单查询(.NET /api/course/v1/order/*) ====================
|
||||
|
||||
export type OrderQueryParams = {
|
||||
PageIndex?: number
|
||||
PageSize?: number
|
||||
KeyWord?: string
|
||||
OrderTypes?: number[]
|
||||
BTime?: string
|
||||
ETime?: string
|
||||
ProductId?: number
|
||||
PackageId?: number
|
||||
Channel?: string
|
||||
ProductIds?: number[]
|
||||
PackageNames?: string[]
|
||||
}
|
||||
|
||||
export type OrderFilters = {
|
||||
orderType: string
|
||||
productId: string
|
||||
@@ -65,3 +117,61 @@ export function cutAccount(accounts: string, max = 15): string {
|
||||
export function totalConnections(item: OrderItem): number {
|
||||
return item.ConnectCount * item.AccountCount
|
||||
}
|
||||
|
||||
// ==================== 表格展示辅助 ====================
|
||||
|
||||
export function formatMoney(value: number): string {
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
export function formatDate(value: string): string {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(date.getDate()).padStart(2, "0")
|
||||
return `${year}.${month}.${day}`
|
||||
}
|
||||
|
||||
// 前端筛选(后端筛选接口就绪后可移除)
|
||||
export function matchOrders(
|
||||
list: OrderItem[],
|
||||
filters: OrderFilters,
|
||||
): OrderItem[] {
|
||||
const keyword = filters.keyword.trim()
|
||||
|
||||
return list.filter(order => {
|
||||
if (
|
||||
filters.orderType !== "0" &&
|
||||
order.OrderType !== Number(filters.orderType)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.productId !== "0" &&
|
||||
order.ProductId !== Number(filters.productId)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
filters.packageName !== "0" &&
|
||||
order.PackageName !== filters.packageName
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
keyword &&
|
||||
!order.OrderNo.includes(keyword) &&
|
||||
!order.Accounts.includes(keyword)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||
return false
|
||||
}
|
||||
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
279
src/lib/models/package.ts
Normal file
279
src/lib/models/package.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
// 短效/长效套餐相关模型(与 php-api.juip.com 的 /http/user/* 接口对应)
|
||||
|
||||
// 短效套餐(已开通)
|
||||
export type DxPackage = {
|
||||
id: number
|
||||
name: string
|
||||
planType: number
|
||||
remainingAmount: number | string
|
||||
durationType: string
|
||||
durationType2?: number
|
||||
expireTime: string
|
||||
isExpired?: boolean | number | string
|
||||
ipAmount?: number
|
||||
}
|
||||
|
||||
// 短效购买记录
|
||||
export type DxOrder = {
|
||||
order_no: string
|
||||
order_type: number | string
|
||||
package_type: string
|
||||
durationType: string
|
||||
money: number | string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
// 长效套餐(已开通)
|
||||
export type CxPackage = {
|
||||
id: number
|
||||
isAbroad: number | string
|
||||
isAbroad2?: number
|
||||
shareType: number | string
|
||||
ipAmount: number
|
||||
expireTime: string
|
||||
isRelayed?: number
|
||||
}
|
||||
|
||||
// 长效购买记录
|
||||
export type CxOrder = {
|
||||
order_no: string
|
||||
order_type: number | string
|
||||
game_type: string
|
||||
ip_count: number
|
||||
money: number | string
|
||||
create_time: string
|
||||
}
|
||||
|
||||
// 已开通长效 IP
|
||||
export type LangIpItem = {
|
||||
id: number
|
||||
packId?: number
|
||||
isRelayed: number
|
||||
isAbroad: number
|
||||
ip: string
|
||||
realIp: string
|
||||
httpPort: number
|
||||
socks5Port: number
|
||||
area: string
|
||||
bandwidth: number
|
||||
lineType: number | string
|
||||
lineType2?: number
|
||||
shareType: number | string
|
||||
shareType2?: number
|
||||
state: number | string
|
||||
username: string
|
||||
password: string
|
||||
expireTime: string
|
||||
isExpired: boolean | number | string
|
||||
gameId?: number
|
||||
isp?: number
|
||||
}
|
||||
|
||||
// 长效 IP 切换记录
|
||||
export type SwitchRecord = {
|
||||
oldIp: string
|
||||
oldArea: string
|
||||
newIp: string
|
||||
newArea: string
|
||||
reason: string
|
||||
createTime: string
|
||||
}
|
||||
|
||||
// 服务端分页信息
|
||||
export type Pager = {
|
||||
currentPage: number
|
||||
pageSize?: number
|
||||
totalCount?: number
|
||||
totalPage?: number
|
||||
}
|
||||
|
||||
// 长效 IP 列表查询结果
|
||||
export type LangIpListResult = {
|
||||
items: LangIpItem[]
|
||||
pager: Pager
|
||||
}
|
||||
|
||||
// 长效 IP 切换记录查询结果
|
||||
export type SwitchListResult = {
|
||||
items: SwitchRecord[]
|
||||
pager: Pager
|
||||
}
|
||||
|
||||
// 下单/续费/切换通用返回
|
||||
export type OrderActionResult = {
|
||||
code: number
|
||||
msg?: string
|
||||
}
|
||||
|
||||
// 批量提取的长效 IP
|
||||
export type LangIpExtract = {
|
||||
ip: string
|
||||
httpPort: number
|
||||
socks5Port: number
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
// 短效提取用户凭证(get_user_token)
|
||||
export type UserApiToken = {
|
||||
uid: number | string
|
||||
token: string
|
||||
}
|
||||
|
||||
// 续费/切换下单数据
|
||||
export type RebuyData = {
|
||||
title?: string
|
||||
packId?: number
|
||||
packType?: number
|
||||
periodType: number
|
||||
periodAmount: number
|
||||
durationType?: number
|
||||
ipAmount: number
|
||||
ids?: string
|
||||
isRelayed?: number
|
||||
isAbroad?: number
|
||||
shareType?: number
|
||||
lineType?: number
|
||||
bandwidth?: number
|
||||
}
|
||||
|
||||
// 套餐订单信息(order_type: 2-短效无限量 3-短效包天 5-长效套餐 6-单IP续费 7-切换IP)
|
||||
export type PackageOrderInfo = {
|
||||
order_type: number
|
||||
money: number
|
||||
pay_type?: number
|
||||
data: RebuyData
|
||||
}
|
||||
|
||||
// 长效 IP 切换节点/入口下单数据
|
||||
export type ChangeIpData = {
|
||||
title?: string
|
||||
id: number
|
||||
isAbroad: number
|
||||
cityCode: number
|
||||
isp: number
|
||||
switchPop: 0 | 1
|
||||
data: RebuyData
|
||||
}
|
||||
|
||||
// 短效 API 链接生成参数
|
||||
export type ApiLinkConfig = {
|
||||
pack?: number
|
||||
num: number
|
||||
gt?: number
|
||||
port: number
|
||||
type: number
|
||||
lb: number
|
||||
clb?: string
|
||||
distinct: number
|
||||
isp: number
|
||||
aw: number
|
||||
at: number
|
||||
sp: number
|
||||
csp?: string
|
||||
pt?: number
|
||||
time?: number
|
||||
u?: number | string
|
||||
t?: string
|
||||
}
|
||||
|
||||
// 短效套餐类型
|
||||
export const SHORT_PLAN_DAILY = 21 // 短效包天
|
||||
export const SHORT_PLAN_VOLUME = 22 // 短效包量
|
||||
export const SHORT_PLAN_UNLIMITED = 23 // 短效无限量
|
||||
export const LONG_PLAN = 31 // 长效套餐
|
||||
|
||||
// 套餐周期选项
|
||||
export const PERIOD_OPTIONS = [
|
||||
{ value: 1, label: "按天", unit: "天" },
|
||||
{ value: 7, label: "按周", unit: "周" },
|
||||
{ value: 30, label: "按月", unit: "月" },
|
||||
{ value: 90, label: "按季", unit: "季" },
|
||||
] as const
|
||||
|
||||
// 长效订单类型
|
||||
export const LONG_ORDER_TYPE_LABELS: Record<number, string> = {
|
||||
5: "长效游戏",
|
||||
6: "单IP续费",
|
||||
7: "切换IP",
|
||||
}
|
||||
|
||||
// 短效订单类型
|
||||
export const SHORT_ORDER_TYPE_LABELS: Record<number, string> = {
|
||||
2: "短效无限量",
|
||||
3: "短效包天",
|
||||
4: "短效包量",
|
||||
}
|
||||
|
||||
// 长效 IP 状态
|
||||
export const IP_STATE_LABELS: Record<number, string> = {
|
||||
1: "正常",
|
||||
0: "异常",
|
||||
9: "封禁",
|
||||
}
|
||||
|
||||
// ==================== 字段格式化 ====================
|
||||
|
||||
// 后端的 isExpired 可能返回 true/1/"1",统一转布尔
|
||||
export function parseExpired(
|
||||
value: boolean | number | string | undefined,
|
||||
): boolean {
|
||||
return value === true || value === 1 || value === "1"
|
||||
}
|
||||
|
||||
export function formatExpired(
|
||||
value: boolean | number | string | undefined,
|
||||
): string {
|
||||
return parseExpired(value) ? "已过期" : "未过期"
|
||||
}
|
||||
|
||||
export function formatAbroad(value: number | string | undefined): string {
|
||||
return value === 1 || value === "1" ? "海外" : "国内"
|
||||
}
|
||||
|
||||
export function formatIpState(value: number | string): string {
|
||||
const num = Number(value)
|
||||
return IP_STATE_LABELS[num] ?? String(value)
|
||||
}
|
||||
|
||||
export function formatOrderTypeLabel(
|
||||
labels: Record<number, string>,
|
||||
value: number | string,
|
||||
): string {
|
||||
return labels[Number(value)] ?? String(value)
|
||||
}
|
||||
|
||||
// 提取地区选项
|
||||
export const REGION_OPTIONS = [
|
||||
{ value: "110000", label: "北京市" },
|
||||
{ value: "120000", label: "天津市" },
|
||||
{ value: "130000", label: "河北省" },
|
||||
{ value: "140000", label: "山西省" },
|
||||
{ value: "210000", label: "辽宁省" },
|
||||
{ value: "220000", label: "吉林省" },
|
||||
{ value: "310000", label: "上海市" },
|
||||
{ value: "320000", label: "江苏省" },
|
||||
{ value: "330000", label: "浙江省" },
|
||||
{ value: "340000", label: "安徽省" },
|
||||
{ value: "350000", label: "福建省" },
|
||||
{ value: "360000", label: "江西省" },
|
||||
{ value: "370000", label: "山东省" },
|
||||
{ value: "410000", label: "河南省" },
|
||||
{ value: "420000", label: "湖北省" },
|
||||
{ value: "430000", label: "湖南省" },
|
||||
{ value: "440000", label: "广东省" },
|
||||
{ value: "460000", label: "海南省" },
|
||||
{ value: "500000", label: "重庆市" },
|
||||
{ value: "510000", label: "四川省" },
|
||||
{ value: "520000", label: "贵州省" },
|
||||
{ value: "530000", label: "云南省" },
|
||||
{ value: "610000", label: "陕西省" },
|
||||
{ value: "620000", label: "甘肃省" },
|
||||
{ value: "630000", label: "青海省" },
|
||||
{ value: "230000", label: "黑龙江省" },
|
||||
{ value: "540000", label: "西藏自治区" },
|
||||
{ value: "150000", label: "内蒙古自治区" },
|
||||
{ value: "450000", label: "广西壮族自治区" },
|
||||
{ value: "640000", label: "宁夏回族自治区" },
|
||||
{ value: "650000", label: "新疆维吾尔自治区" },
|
||||
] as const
|
||||
11
src/lib/models/rosorder.ts
Normal file
11
src/lib/models/rosorder.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type RosOrderItem = {
|
||||
id: number
|
||||
username: string
|
||||
product_name: string
|
||||
real_price: number
|
||||
order_no: string
|
||||
user: string
|
||||
phone: string
|
||||
address: string
|
||||
create_time: string
|
||||
}
|
||||
16
src/lib/models/user.ts
Normal file
16
src/lib/models/user.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// 用户(.NET /api/baseinfo/v1/user/*,对应 UserEntity)
|
||||
export type UserItem = {
|
||||
Id: number
|
||||
LoginCode: string
|
||||
Name: string
|
||||
Phone: string
|
||||
Enabled: number
|
||||
PhotoUrl?: string
|
||||
LastLoginDate?: string
|
||||
}
|
||||
|
||||
export type UserQueryParams = {
|
||||
PageIndex?: number
|
||||
PageSize?: number
|
||||
KeyWord?: string
|
||||
}
|
||||
6
src/lib/models/whitelist.ts
Normal file
6
src/lib/models/whitelist.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type WhiteIpItem = {
|
||||
id: number
|
||||
ip: string
|
||||
createTime: string
|
||||
isLocked: string
|
||||
}
|
||||
@@ -8,3 +8,49 @@ export type Children = { children: ReactNode }
|
||||
export function cn(...inputs: ClassNameValue[]) {
|
||||
return twMerge(inputs)
|
||||
}
|
||||
|
||||
// 以文本文件形式下载内容
|
||||
export function downloadTextFile(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement("a")
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.style.display = "none"
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 导出 CSV(带 BOM,避免 Excel 打开中文乱码)
|
||||
export function downloadCsv(
|
||||
filename: string,
|
||||
header: string[],
|
||||
rows: (string | number)[][],
|
||||
) {
|
||||
const lines = [header, ...rows].map(line =>
|
||||
line.map(v => `"${String(v).replaceAll('"', '""')}"`).join(","),
|
||||
)
|
||||
const csv = lines.join("\r\n")
|
||||
const blob = new Blob([`\uFEFF${csv}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename.endsWith(".csv") ? filename : `${filename}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// 服务端未返回总条数时,按当前页数据量估算
|
||||
export function estimateTotal(
|
||||
pagerTotal: number | undefined,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
itemCount: number,
|
||||
): number {
|
||||
if (pagerTotal && pagerTotal > 0) return pagerTotal
|
||||
return itemCount > 0 ? (page - 1) * pageSize + itemCount : 0
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createBrowserRouter } from "react-router-dom"
|
||||
import CashOutPage from "@/admin/cashOut/page"
|
||||
import Channels from "@/admin/channels/page"
|
||||
import CouponPage from "@/admin/coupon/page"
|
||||
import Dashboard from "@/admin/dashboard/page"
|
||||
@@ -12,6 +13,7 @@ import LongPage from "@/admin/resources/long"
|
||||
import ShortPage from "@/admin/resources/short"
|
||||
import RosorderPage from "@/admin/rosorder/page"
|
||||
import Whitelist from "@/admin/whitelist/page"
|
||||
import WhitelistSUPage from "@/admin/whitelistSU/page"
|
||||
import AuthGuard from "@/components/authGuard"
|
||||
import HomePage from "@/home"
|
||||
import ClientPage from "@/home/client"
|
||||
@@ -57,6 +59,7 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <Dashboard /> },
|
||||
{ path: "channels", element: <Channels /> },
|
||||
{ path: "whitelist", element: <Whitelist /> },
|
||||
{ path: "whitelistSU", element: <WhitelistSUPage /> },
|
||||
{ path: "funds", element: <Funds /> },
|
||||
{
|
||||
path: "product",
|
||||
@@ -65,6 +68,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "short", element: <ShortPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "cashOut", element: <CashOutPage /> },
|
||||
{ path: "orders", element: <OrdersPage /> },
|
||||
{ path: "refundOrders", element: <RefundOrdersPage /> },
|
||||
{ path: "httpRecharge", element: <HttpRechargePage /> },
|
||||
|
||||
Reference in New Issue
Block a user