补充个人中心下的页面
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 { Field, FieldError, FieldLabel } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import type { ChannelRecord } from "@/lib/models/channel"
|
import type { ChannelRecord } from "@/lib/models/channel"
|
||||||
|
import { INITIAL_CHANNELS } from "./mock"
|
||||||
|
|
||||||
|
// TODO: 后端就绪后替换为 fetchChannelPage(见 src/api/channel.ts)
|
||||||
|
|
||||||
const filterSchema = z.object({
|
const filterSchema = z.object({
|
||||||
expired_at_end: z.string().optional(),
|
expired_at_end: z.string().optional(),
|
||||||
@@ -21,61 +24,8 @@ const filterSchema = z.object({
|
|||||||
|
|
||||||
type FormValues = z.infer<typeof filterSchema>
|
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() {
|
export default function ChannelsPage() {
|
||||||
const [filterValues, setFilterValues] = useState<FormValues>({})
|
const [filterValues, setFilterValues] = useState<FormValues>({})
|
||||||
const [status, setStatus] = useState<"load" | "done">("done")
|
|
||||||
// 行选择状态
|
|
||||||
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
const { control, handleSubmit, reset } = useForm<FormValues>({
|
const { control, handleSubmit, reset } = useForm<FormValues>({
|
||||||
@@ -91,10 +41,10 @@ export default function ChannelsPage() {
|
|||||||
|
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!filterValues || Object.values(filterValues).every(value => !value)) {
|
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) {
|
if (filterValues.expired_at_end) {
|
||||||
return record.expiredAt <= filterValues.expired_at_end
|
return record.expiredAt <= filterValues.expired_at_end
|
||||||
}
|
}
|
||||||
@@ -126,12 +76,8 @@ export default function ChannelsPage() {
|
|||||||
}, [filterValues])
|
}, [filterValues])
|
||||||
|
|
||||||
const onSubmit = (values: FormValues) => {
|
const onSubmit = (values: FormValues) => {
|
||||||
setStatus("load")
|
|
||||||
setTimeout(() => {
|
|
||||||
setFilterValues(values)
|
setFilterValues(values)
|
||||||
setRowSelection({})
|
setRowSelection({})
|
||||||
setStatus("done")
|
|
||||||
}, 200)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReset = () => {
|
const onReset = () => {
|
||||||
@@ -149,7 +95,8 @@ export default function ChannelsPage() {
|
|||||||
// 批量续费
|
// 批量续费
|
||||||
const handleRenew = () => {
|
const handleRenew = () => {
|
||||||
const rows = getSelectedRows()
|
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("请选择一条数据进行退货")
|
toast.error("请选择一条数据进行退货")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log("退货数据:", rows[0])
|
// TODO: 后端退货接口就绪后接入
|
||||||
|
toast.info(`退货账号:${rows[0].account}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -257,7 +205,7 @@ export default function ChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<DataTable<ChannelRecord>
|
<DataTable<ChannelRecord>
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
status={status}
|
status="done"
|
||||||
columns={[
|
columns={[
|
||||||
{ accessorKey: "startAt", header: "开通时间" },
|
{ accessorKey: "startAt", header: "开通时间" },
|
||||||
{ accessorKey: "product", header: "产品" },
|
{ accessorKey: "product", header: "产品" },
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Clock1,
|
Clock1,
|
||||||
FolderCode,
|
FolderCode,
|
||||||
LayoutDashboard,
|
|
||||||
PanelLeftCloseIcon,
|
PanelLeftCloseIcon,
|
||||||
PanelLeftOpenIcon,
|
PanelLeftOpenIcon,
|
||||||
ReceiptJapaneseYenIcon,
|
ReceiptJapaneseYenIcon,
|
||||||
@@ -14,6 +13,7 @@ import {
|
|||||||
ShoppingBag,
|
ShoppingBag,
|
||||||
TicketPercent,
|
TicketPercent,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
|
UserRound,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { type ReactNode, useState } from "react"
|
import { type ReactNode, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
@@ -150,16 +150,12 @@ export function Navbar() {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin"
|
href="/admin"
|
||||||
icon={<LayoutDashboard size={20} />}
|
icon={<UserRound size={20} />}
|
||||||
label="账户总览"
|
label="个人中心"
|
||||||
expand={navbar}
|
|
||||||
/>
|
|
||||||
<NavItem
|
|
||||||
href="/admin/whitelist"
|
|
||||||
icon={<UserCheck size={20} />}
|
|
||||||
label="白名单"
|
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
|
<NavTitle label="IP管理" />
|
||||||
|
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/channels"
|
href="/admin/channels"
|
||||||
icon={<FolderCode size={20} />}
|
icon={<FolderCode size={20} />}
|
||||||
@@ -172,6 +168,12 @@ export function Navbar() {
|
|||||||
label="资金明细"
|
label="资金明细"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
|
<NavItem
|
||||||
|
href="/admin/rosorder"
|
||||||
|
icon={<Router size={20} />}
|
||||||
|
label="软路由订单"
|
||||||
|
expand={navbar}
|
||||||
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/orders"
|
href="/admin/orders"
|
||||||
icon={<ShoppingBag size={20} />}
|
icon={<ShoppingBag size={20} />}
|
||||||
@@ -181,21 +183,22 @@ export function Navbar() {
|
|||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/refundOrders"
|
href="/admin/refundOrders"
|
||||||
icon={<ClipboardList size={20} />}
|
icon={<ClipboardList size={20} />}
|
||||||
label="退货管理"
|
label="退货订单管理"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/coupon"
|
href="/admin/coupon"
|
||||||
icon={<TicketPercent size={20} />}
|
icon={<TicketPercent size={20} />}
|
||||||
label="优惠券"
|
label="我的优惠券"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/channels"
|
href="/admin/cashOut"
|
||||||
icon={<ReceiptJapaneseYenIcon size={20} />}
|
icon={<ReceiptJapaneseYenIcon size={20} />}
|
||||||
label="提现管理"
|
label="提现管理"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
|
<NavTitle label="HTTP管理" />
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/httpRecharge"
|
href="/admin/httpRecharge"
|
||||||
icon={<Archive size={20} />}
|
icon={<Archive size={20} />}
|
||||||
@@ -214,18 +217,24 @@ export function Navbar() {
|
|||||||
label="长效管理"
|
label="长效管理"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
|
||||||
href="/admin/rosorder"
|
|
||||||
icon={<Router size={20} />}
|
|
||||||
label="软路由"
|
|
||||||
expand={navbar}
|
|
||||||
/>
|
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/record"
|
href="/admin/record"
|
||||||
icon={<ClipboardClock size={20} />}
|
icon={<ClipboardClock size={20} />}
|
||||||
label="使用记录"
|
label="使用记录"
|
||||||
expand={navbar}
|
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>
|
</TooltipProvider>
|
||||||
</section>
|
</section>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -287,3 +296,31 @@ function NavItem(props: {
|
|||||||
</Tooltip>
|
</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 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() {
|
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 b12 from "../assets/b12.webp"
|
||||||
import banner from "../assets/banner.webp"
|
import banner from "../assets/banner.webp"
|
||||||
import banner2 from "../assets/banner2.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() {
|
export default function DashboardPage() {
|
||||||
// ========== 数据定义 ==========
|
// ========== 数据定义(待接口就绪后改为 useEffect 拉取) ==========
|
||||||
const balanceData = {
|
const balanceData = MOCK_BALANCE
|
||||||
balance: 68.0,
|
const accountInfo = MOCK_ACCOUNT_INFO
|
||||||
hCoin: 68.0,
|
const recentOrders = MOCK_RECENT_ORDERS
|
||||||
}
|
const consumptionData = MOCK_CONSUMPTION
|
||||||
|
const ipAccountData = MOCK_IP_STATS
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
|
|
||||||
const handleSaveInfo = (data: EditInfoValues) => {
|
const handleSaveInfo = (data: EditInfoValues) => {
|
||||||
console.log("保存的信息:", data)
|
// TODO: 接入 src/api/user.ts updateUserProfile
|
||||||
|
void data
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 渲染 ==========
|
|
||||||
return (
|
return (
|
||||||
<Page
|
<Page
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -226,7 +186,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<h3 className="font-semibold text-lg mb-4 shrink-0">最近订单</h3>
|
||||||
<div className="flex-1 overflow-auto min-h-0">
|
<div className="flex-1 overflow-auto min-h-0">
|
||||||
@@ -259,8 +219,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ====== 第三、四行:消费信息 + IP账号 ====== */}
|
{/* ====== 消费信息 + IP账号 ====== */}
|
||||||
{/* 消费信息 - 占左侧3列,跨2行 */}
|
|
||||||
<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="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">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="font-semibold text-lg">消费信息</h3>
|
<h3 className="font-semibold text-lg">消费信息</h3>
|
||||||
@@ -373,7 +332,7 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<h3 className="font-semibold text-lg mb-6">IP账号</h3>
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import DataTable from "@/components/data-table"
|
import DataTable from "@/components/data-table"
|
||||||
import Page from "@/components/page"
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function FundsPage() {
|
export default function FundsPage() {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import DataTable from "@/components/data-table"
|
import DataTable from "@/components/data-table"
|
||||||
import Page from "@/components/page"
|
import Page from "@/components/page"
|
||||||
|
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
|
||||||
|
|
||||||
|
|
||||||
export default function HttpRechargePage() {
|
export default function HttpRechargePage() {
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
|
<div className="bg-white rounded-lg p-4">
|
||||||
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
|
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
|
||||||
<DataTable
|
<DataTable<HttpRechargeItem>
|
||||||
data={[]}
|
data={[]}
|
||||||
status="done"
|
status="done"
|
||||||
columns={[
|
columns={[
|
||||||
@@ -27,6 +30,7 @@ export default function HttpRechargePage() {
|
|||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</Page>
|
</Page>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ColumnDef } from "@tanstack/react-table"
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
import { SearchIcon } from "lucide-react"
|
import { SearchIcon } from "lucide-react"
|
||||||
import { useMemo, useState } from "react"
|
import AccountCell from "@/components/composites/accountCell"
|
||||||
import DataTable from "@/components/data-table"
|
import DataTable from "@/components/data-table"
|
||||||
import Page from "@/components/page"
|
import Page from "@/components/page"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -12,49 +12,19 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { useOrderFilters } from "@/lib/hooks/useOrderFilters"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
formatDate,
|
||||||
TooltipContent,
|
formatMoney,
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import {
|
|
||||||
cutAccount,
|
|
||||||
DEFAULT_FILTERS,
|
|
||||||
formatOrderType,
|
formatOrderType,
|
||||||
ORDER_TYPE_LABELS,
|
ORDER_TYPE_LABELS,
|
||||||
type OrderFilters,
|
|
||||||
type OrderItem,
|
type OrderItem,
|
||||||
PACKAGE_OPTIONS,
|
PACKAGE_OPTIONS,
|
||||||
totalConnections,
|
totalConnections,
|
||||||
} from "@/lib/models/order"
|
} from "@/lib/models/order"
|
||||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
import { MOCK_ORDERS, MOCK_PRODUCTS } from "../mock/orders"
|
||||||
|
|
||||||
function formatMoney(value: number): string {
|
// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchOrderPage 返回的分页数据
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnDef<OrderItem>[] = [
|
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() {
|
export default function OrdersPage() {
|
||||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
const {
|
||||||
const [appliedFilters, setAppliedFilters] =
|
filters,
|
||||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
patch,
|
||||||
const [page, setPage] = useState(1)
|
handleSearch,
|
||||||
const [pageSize, setPageSize] = useState(10)
|
page,
|
||||||
|
setPage,
|
||||||
const filtered = useMemo(
|
pageSize,
|
||||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
handleSizeChange,
|
||||||
[appliedFilters],
|
filtered,
|
||||||
)
|
paged,
|
||||||
|
} = useOrderFilters(MOCK_ORDERS)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -278,10 +197,7 @@ export default function OrdersPage() {
|
|||||||
size: pageSize,
|
size: pageSize,
|
||||||
total: filtered.length,
|
total: filtered.length,
|
||||||
onPageChange: setPage,
|
onPageChange: setPage,
|
||||||
onSizeChange: size => {
|
onSizeChange: handleSizeChange,
|
||||||
setPageSize(size)
|
|
||||||
setPage(1)
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ import {
|
|||||||
} from "@/lib/models/record"
|
} from "@/lib/models/record"
|
||||||
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
|
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
|
||||||
|
|
||||||
|
// TODO: 后端使用记录接口就绪后,将 mock 替换为 src/api/record.ts 的
|
||||||
|
// fetchHttpUsedSummary / fetchHttpUsedLogs(服务端分页)
|
||||||
|
|
||||||
const columns: ColumnDef<HttpUsedLogItem>[] = [
|
const columns: ColumnDef<HttpUsedLogItem>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "packType",
|
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 type { ColumnDef } from "@tanstack/react-table"
|
||||||
import { SearchIcon } from "lucide-react"
|
import { SearchIcon } from "lucide-react"
|
||||||
import { useMemo, useState } from "react"
|
import AccountCell from "@/components/composites/accountCell"
|
||||||
import DataTable from "@/components/data-table"
|
import DataTable from "@/components/data-table"
|
||||||
import Page from "@/components/page"
|
import Page from "@/components/page"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -12,48 +12,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import { useOrderFilters } from "@/lib/hooks/useOrderFilters"
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
formatDate,
|
||||||
TooltipContent,
|
formatMoney,
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip"
|
|
||||||
import {
|
|
||||||
cutAccount,
|
|
||||||
DEFAULT_FILTERS,
|
|
||||||
formatOrderType,
|
|
||||||
type OrderFilters,
|
|
||||||
type OrderItem,
|
type OrderItem,
|
||||||
PACKAGE_OPTIONS,
|
PACKAGE_OPTIONS,
|
||||||
totalConnections,
|
totalConnections,
|
||||||
} from "@/lib/models/order"
|
} from "@/lib/models/order"
|
||||||
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
import { MOCK_ORDERS, MOCK_PRODUCTS } from "../mock/orders"
|
||||||
|
|
||||||
function formatMoney(value: number): string {
|
// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchRefundOrders 返回的分页数据
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnDef<OrderItem>[] = [
|
const columns: ColumnDef<OrderItem>[] = [
|
||||||
{
|
{
|
||||||
@@ -65,11 +34,6 @@ const columns: ColumnDef<OrderItem>[] = [
|
|||||||
accessorKey: "OrderNo",
|
accessorKey: "OrderNo",
|
||||||
header: "订单编号",
|
header: "订单编号",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "OrderType",
|
|
||||||
header: "类型",
|
|
||||||
cell: ({ row }) => formatOrderType(row.original.OrderType),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
accessorKey: "ProductName",
|
accessorKey: "ProductName",
|
||||||
header: "产品",
|
header: "产品",
|
||||||
@@ -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() {
|
export default function RefundOrdersPage() {
|
||||||
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
const {
|
||||||
const [appliedFilters, setAppliedFilters] =
|
filters,
|
||||||
useState<OrderFilters>(DEFAULT_FILTERS)
|
patch,
|
||||||
const [page, setPage] = useState(1)
|
handleSearch,
|
||||||
const [pageSize, setPageSize] = useState(10)
|
page,
|
||||||
|
setPage,
|
||||||
const filtered = useMemo(
|
pageSize,
|
||||||
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
handleSizeChange,
|
||||||
[appliedFilters],
|
filtered,
|
||||||
)
|
paged,
|
||||||
|
} = useOrderFilters(MOCK_ORDERS)
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
@@ -250,7 +163,7 @@ export default function RefundOrdersPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||||
<h3 className="font-semibold text-lg">订单管理</h3>
|
<h3 className="font-semibold text-lg">退款订单管理</h3>
|
||||||
<DataTable
|
<DataTable
|
||||||
data={paged}
|
data={paged}
|
||||||
status="done"
|
status="done"
|
||||||
@@ -260,10 +173,7 @@ export default function RefundOrdersPage() {
|
|||||||
size: pageSize,
|
size: pageSize,
|
||||||
total: filtered.length,
|
total: filtered.length,
|
||||||
onPageChange: setPage,
|
onPageChange: setPage,
|
||||||
onSizeChange: size => {
|
onSizeChange: handleSizeChange,
|
||||||
setPageSize(size)
|
|
||||||
setPage(1)
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 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() {
|
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 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() {
|
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 Page from "@/components/page"
|
||||||
|
import type { RosOrderItem } from "@/lib/models/rosorder"
|
||||||
|
|
||||||
|
// TODO: 软路由订单接口就绪后接入(见 src/api/rosorder.ts fetchRosOrderList)
|
||||||
|
|
||||||
export default function RosorderPage() {
|
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 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() {
|
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
|
Message?: string
|
||||||
Data?: unknown
|
Data?: unknown
|
||||||
data?: unknown
|
data?: unknown
|
||||||
|
TotalCount?: number
|
||||||
}
|
}
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
return {
|
return {
|
||||||
@@ -85,6 +86,8 @@ async function request<R = undefined>(
|
|||||||
success: true,
|
success: true,
|
||||||
data: (json.Data ?? json.data ?? undefined) as R,
|
data: (json.Data ?? json.data ?? undefined) as R,
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
|
totalCount:
|
||||||
|
typeof json.TotalCount === "number" ? json.TotalCount : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -168,7 +171,28 @@ async function call<R = undefined>(
|
|||||||
data?: unknown,
|
data?: unknown,
|
||||||
method: "GET" | "POST" = "POST",
|
method: "GET" | "POST" = "POST",
|
||||||
): Promise<ApiResponse<R>> {
|
): 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 run = async (): Promise<ApiResponse<R>> => {
|
||||||
const maxAttempts = mode === "device" ? 2 : 1
|
const maxAttempts = mode === "device" ? 2 : 1
|
||||||
@@ -202,7 +226,7 @@ async function call<R = undefined>(
|
|||||||
auth = `Bearer ${token}`
|
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 (!resp.success && resp.status === 401) {
|
||||||
if (mode === "user") {
|
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,
|
GameOrderData,
|
||||||
HttpOrderInfo,
|
HttpOrderInfo,
|
||||||
} from "@/lib/models/http"
|
} 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 =
|
export const PHP_API_BASE_URL =
|
||||||
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
|
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>
|
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,
|
path: string,
|
||||||
body: unknown,
|
body?: unknown,
|
||||||
|
options: { credentials?: RequestCredentials } = {},
|
||||||
): Promise<ApiResponse<T>> {
|
): Promise<ApiResponse<T>> {
|
||||||
|
const { credentials = "same-origin" } = options
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
|
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
|
||||||
method: "POST",
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||||
credentials: "include",
|
credentials,
|
||||||
body: JSON.stringify(body),
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
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
|
const d = raw?.d
|
||||||
if (d && typeof d === "object" && !Array.isArray(d)) {
|
if (d && typeof d === "object" && !Array.isArray(d)) {
|
||||||
return d as Record<string, unknown>
|
return d as Record<string, unknown>
|
||||||
@@ -63,43 +106,71 @@ function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
|
|||||||
return {}
|
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(
|
export async function fetchGameCities(
|
||||||
game: GameOrderData,
|
game: GameOrderData,
|
||||||
): Promise<ApiResponse<GameCity[]>> {
|
): 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
|
if (!res.success) return res
|
||||||
const cities = unwrapD(res.data).cities
|
const cities = unwrapD(res.data).cities
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: Array.isArray(cities) ? (cities as GameCity[]) : [],
|
data: isArray<GameCity>(cities) ? cities : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGameList(
|
export async function fetchGameList(
|
||||||
game: GameOrderData,
|
game: GameOrderData,
|
||||||
): Promise<ApiResponse<GameItem[]>> {
|
): 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
|
if (!res.success) return res
|
||||||
const games = unwrapD(res.data).games
|
const games = unwrapD(res.data).games
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: Array.isArray(games) ? (games as GameItem[]) : [],
|
data: isArray<GameItem>(games) ? games : [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGameLineCount(
|
export async function fetchGameLineCount(
|
||||||
game: GameOrderData,
|
game: GameOrderData,
|
||||||
): Promise<ApiResponse<number>> {
|
): 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
|
if (!res.success) return res
|
||||||
const count = unwrapD(res.data).count
|
const count = unwrapD(res.data).count
|
||||||
return { success: true, data: Number(count) || 0 }
|
return { success: true, data: toNumber(count) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calcGamePrice(
|
export async function calcGamePrice(
|
||||||
orderInfo: HttpOrderInfo,
|
orderInfo: HttpOrderInfo,
|
||||||
): Promise<ApiResponse<number>> {
|
): 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",
|
"/http/product/calc_price",
|
||||||
orderInfo,
|
orderInfo,
|
||||||
)
|
)
|
||||||
@@ -113,10 +184,12 @@ export async function calcGamePrice(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 订单 ====================
|
||||||
|
|
||||||
export async function createHttpOrder(
|
export async function createHttpOrder(
|
||||||
orderInfo: HttpOrderInfo,
|
orderInfo: HttpOrderInfo,
|
||||||
): Promise<ApiResponse<CreateOrderResult>> {
|
): Promise<ApiResponse<CreateOrderResult>> {
|
||||||
const res = await postJson<RawHttpResponse>("/http/order/create_order", {
|
const res = await phpPost<RawHttpResponse>("/http/order/create_order", {
|
||||||
cookie: document.cookie,
|
cookie: document.cookie,
|
||||||
order_info: orderInfo,
|
order_info: orderInfo,
|
||||||
})
|
})
|
||||||
@@ -124,23 +197,187 @@ export async function createHttpOrder(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
code: Number(res.data?.code) || 0,
|
code: toNumber(res.data?.code),
|
||||||
msg: typeof res.data?.msg === "string" ? res.data.msg : undefined,
|
msg: typeof res.data?.msg === "string" ? res.data.msg : undefined,
|
||||||
data: typeof res.data?.data === "string" ? res.data.data : undefined,
|
data: typeof res.data?.data === "string" ? res.data.data : undefined,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchHttpBalance(): Promise<ApiResponse<number>> {
|
function parseOrderAction(raw: RawHttpResponse): OrderActionResult {
|
||||||
const res = await postJson<RawHttpResponse | number>(
|
return {
|
||||||
"/http/user/get_balance",
|
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,
|
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 (!res.success) return res
|
||||||
if (typeof res.data === "number") return { success: true, data: res.data }
|
if (typeof res.data === "number") return { success: true, data: res.data }
|
||||||
const raw = res.data ?? {}
|
const raw = res.data ?? {}
|
||||||
const balance = raw.d ?? raw.data ?? raw.balance
|
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"
|
import type { ApiResponse } from "@/lib/api"
|
||||||
|
import type { LineData, LineSearchResult } from "@/lib/models/line"
|
||||||
export const PHP_API_BASE_URL =
|
import { phpGet } from "./http"
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
|
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
|
||||||
return getJson<LineData>(
|
return phpGet<LineData>(`/script/linedata/display.php?product=${product}`)
|
||||||
`${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function searchLineData(
|
export function searchLineData(
|
||||||
@@ -67,7 +11,5 @@ export function searchLineData(
|
|||||||
info: string,
|
info: string,
|
||||||
): Promise<ApiResponse<LineSearchResult>> {
|
): Promise<ApiResponse<LineSearchResult>> {
|
||||||
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
||||||
return getJson<LineSearchResult>(
|
return phpGet<LineSearchResult>(`/script/linedata/search.php?${query}`)
|
||||||
`${PHP_API_BASE_URL}/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 { ApiResponse } from "@/lib/api"
|
||||||
|
import type {
|
||||||
|
CreateOrderData,
|
||||||
|
CreateOrderRequest,
|
||||||
|
CreateTestAccountRequest,
|
||||||
|
OrderItem,
|
||||||
|
OrderQueryParams,
|
||||||
|
} from "@/lib/models/order"
|
||||||
import { callByUser } from "./base"
|
import { callByUser } from "./base"
|
||||||
|
|
||||||
export type CreateOrderRequest = {
|
// ==================== 购买下单(php-api /product/*) ====================
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createOrder(
|
export async function createOrder(
|
||||||
data: CreateOrderRequest,
|
data: CreateOrderRequest,
|
||||||
@@ -62,3 +37,71 @@ export async function checkPayStatus(
|
|||||||
"GET",
|
"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 { ApiResponse } from "@/lib/api"
|
||||||
|
import type { SMSPurpose } from "@/lib/models/auth"
|
||||||
import { callPublic } from "./base"
|
import { callPublic } from "./base"
|
||||||
|
|
||||||
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
|
|
||||||
|
|
||||||
export async function sendSMS(
|
export async function sendSMS(
|
||||||
phone: string,
|
phone: string,
|
||||||
purpose: SMSPurpose = "User_Code",
|
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 type { ColumnDef } from "@tanstack/react-table"
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import {
|
import { PHP_API_BASE_URL } from "@/api/http"
|
||||||
fetchLineData,
|
import { fetchLineData, searchLineData } from "@/api/linedata"
|
||||||
type LineItem,
|
|
||||||
PHP_API_BASE_URL,
|
|
||||||
searchLineData,
|
|
||||||
} from "@/api/linedata"
|
|
||||||
import DataTable from "@/components/data-table"
|
import DataTable from "@/components/data-table"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import Wrap from "@/components/wrap"
|
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 REFRESH_INTERVAL = 5 * 60 * 1000
|
||||||
const PAGE_SIZE_OPTIONS = [100, 200, 500]
|
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 {
|
function toText(value: string | number): string {
|
||||||
return value === null || value === undefined ? "" : String(value)
|
return value === null || value === undefined ? "" : String(value)
|
||||||
}
|
}
|
||||||
@@ -120,7 +101,6 @@ export default function IpLinePage() {
|
|||||||
setPageIndex(0)
|
setPageIndex(0)
|
||||||
const seq = ++requestSeq.current
|
const seq = ++requestSeq.current
|
||||||
const result = await searchLineData(productId ?? 0, info)
|
const result = await searchLineData(productId ?? 0, info)
|
||||||
console.log("搜索的result", result)
|
|
||||||
|
|
||||||
if (seq !== requestSeq.current) return
|
if (seq !== requestSeq.current) return
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -154,29 +134,15 @@ export default function IpLinePage() {
|
|||||||
"带宽",
|
"带宽",
|
||||||
"服务器状态",
|
"服务器状态",
|
||||||
]
|
]
|
||||||
const lines = [
|
const data = rows.map(r => [
|
||||||
header,
|
|
||||||
...rows.map(r => [
|
|
||||||
toText(r.name),
|
toText(r.name),
|
||||||
toText(r.city),
|
toText(r.city),
|
||||||
toText(r.supply),
|
toText(r.supply),
|
||||||
toText(r.nasname),
|
toText(r.nasname),
|
||||||
toText(r.daikuan),
|
toText(r.daikuan),
|
||||||
toText(r.online),
|
toText(r.online),
|
||||||
]),
|
])
|
||||||
]
|
downloadCsv(`${currentName || "IP线路表"}.csv`, header, data)
|
||||||
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)
|
|
||||||
toast.success("导出成功")
|
toast.success("导出成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useLocation, useNavigate } from "react-router-dom"
|
import { useLocation, useNavigate } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { type CreateOrderRequest, createOrder } from "@/api/order"
|
import { createOrder } from "@/api/order"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import Wrap from "@/components/wrap"
|
import Wrap from "@/components/wrap"
|
||||||
|
import type { CreateOrderRequest } from "@/lib/models/order"
|
||||||
import type { Product } from "@/lib/models/product"
|
import type { Product } from "@/lib/models/product"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import alipay from "../_assets/alipay.svg"
|
import alipay from "../_assets/alipay.svg"
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function OperatorSelect({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{operators.map((op) => (
|
{operators.map(op => (
|
||||||
<button
|
<button
|
||||||
key={op}
|
key={op}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -129,7 +129,7 @@ function RegionGrid({
|
|||||||
H: "H",
|
H: "H",
|
||||||
I: "I",
|
I: "I",
|
||||||
}
|
}
|
||||||
regions.forEach((r) => {
|
regions.forEach(r => {
|
||||||
const key = letterMap[r.id] || "其他"
|
const key = letterMap[r.id] || "其他"
|
||||||
if (!groupedRegions[key]) groupedRegions[key] = []
|
if (!groupedRegions[key]) groupedRegions[key] = []
|
||||||
groupedRegions[key].push(r)
|
groupedRegions[key].push(r)
|
||||||
@@ -143,7 +143,7 @@ function RegionGrid({
|
|||||||
{letter}
|
{letter}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-wrap gap-2 flex-1">
|
<div className="flex flex-wrap gap-2 flex-1">
|
||||||
{items.map((region) => (
|
{items.map(region => (
|
||||||
<button
|
<button
|
||||||
key={region.id}
|
key={region.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -155,7 +155,9 @@ function RegionGrid({
|
|||||||
onClick={() => onSelect(region.name)}
|
onClick={() => onSelect(region.name)}
|
||||||
>
|
>
|
||||||
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +179,7 @@ function BandwidthSelect({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{options.map((bw) => (
|
{options.map(bw => (
|
||||||
<button
|
<button
|
||||||
key={bw}
|
key={bw}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -211,7 +213,7 @@ function DurationSelect({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{options.map((opt) => (
|
{options.map(opt => (
|
||||||
<button
|
<button
|
||||||
key={opt.value}
|
key={opt.value}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -242,10 +244,10 @@ function ProjectSelect({
|
|||||||
return (
|
return (
|
||||||
<select
|
<select
|
||||||
value={value}
|
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"
|
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}>
|
<option key={p} value={p}>
|
||||||
{p}
|
{p}
|
||||||
</option>
|
</option>
|
||||||
@@ -281,7 +283,12 @@ export default function BuyStaticPage() {
|
|||||||
|
|
||||||
// 根据类型计算价格
|
// 根据类型计算价格
|
||||||
const getPrice = () => {
|
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 durationMap: Record<string, number> = { test: 0.1, day: 1, month: 10 }
|
||||||
const basePrice = bwMap[bandwidth] || 10
|
const basePrice = bwMap[bandwidth] || 10
|
||||||
const durationPrice = durationMap[duration] || 0.1
|
const durationPrice = durationMap[duration] || 0.1
|
||||||
@@ -315,7 +322,9 @@ export default function BuyStaticPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-4">
|
<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">线路切换单日限制为20次</p>
|
||||||
<p className="mt-1 text-sm text-red-500">
|
<p className="mt-1 text-sm text-red-500">
|
||||||
如果没有自己想用的地区线路或线路余量不足,请联系客服上架
|
如果没有自己想用的地区线路或线路余量不足,请联系客服上架
|
||||||
@@ -356,7 +365,7 @@ export default function BuyStaticPage() {
|
|||||||
placeholder="请输入地区"
|
placeholder="请输入地区"
|
||||||
className="h-10 rounded-xl border-gray-200 flex-1"
|
className="h-10 rounded-xl border-gray-200 flex-1"
|
||||||
value={regionSearch}
|
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">
|
<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 className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||||
应付金额:
|
应付金额:
|
||||||
</Label>
|
</Label>
|
||||||
<span className="text-xl font-bold text-orange-500">¥{getPrice()}</span>
|
<span className="text-xl font-bold text-orange-500">
|
||||||
|
¥{getPrice()}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 余额 */}
|
{/* 余额 */}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ export default function ProductPage() {
|
|||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
TAB_SOURCES.map(source => source.fetch()),
|
TAB_SOURCES.map(source => source.fetch()),
|
||||||
)
|
)
|
||||||
console.log(results, "resultsresultsresults")
|
|
||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export default function PurchaseInfo() {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className="w-full overflow-hidden rounded-lg border border-gray-200 shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
|
className="w-full overflow-hidden rounded-lg border border-gray-200 shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
onClick={() => setIsImageOpen(true)}
|
onClick={() => setIsImageOpen(true)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={e => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsImageOpen(true)
|
setIsImageOpen(true)
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ export default function PurchaseInfo() {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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()
|
e.stopPropagation()
|
||||||
setIsImageOpen(false)
|
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">
|
<div className="text-sm text-gray-500 space-y-1.5 mt-5">
|
||||||
{pool.features.map((feature, index) => (
|
{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}
|
{feature}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ type ApiResponse<T = undefined> =
|
|||||||
data: T
|
data: T
|
||||||
// 响应头(key 统一小写),用于登录等场景从 header 取数据
|
// 响应头(key 统一小写),用于登录等场景从 header 取数据
|
||||||
headers?: Record<string, string>
|
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
|
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 = {
|
export type OrderFilters = {
|
||||||
orderType: string
|
orderType: string
|
||||||
productId: string
|
productId: string
|
||||||
@@ -65,3 +117,61 @@ export function cutAccount(accounts: string, max = 15): string {
|
|||||||
export function totalConnections(item: OrderItem): number {
|
export function totalConnections(item: OrderItem): number {
|
||||||
return item.ConnectCount * item.AccountCount
|
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[]) {
|
export function cn(...inputs: ClassNameValue[]) {
|
||||||
return twMerge(inputs)
|
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 { createBrowserRouter } from "react-router-dom"
|
||||||
|
import CashOutPage from "@/admin/cashOut/page"
|
||||||
import Channels from "@/admin/channels/page"
|
import Channels from "@/admin/channels/page"
|
||||||
import CouponPage from "@/admin/coupon/page"
|
import CouponPage from "@/admin/coupon/page"
|
||||||
import Dashboard from "@/admin/dashboard/page"
|
import Dashboard from "@/admin/dashboard/page"
|
||||||
@@ -12,6 +13,7 @@ import LongPage from "@/admin/resources/long"
|
|||||||
import ShortPage from "@/admin/resources/short"
|
import ShortPage from "@/admin/resources/short"
|
||||||
import RosorderPage from "@/admin/rosorder/page"
|
import RosorderPage from "@/admin/rosorder/page"
|
||||||
import Whitelist from "@/admin/whitelist/page"
|
import Whitelist from "@/admin/whitelist/page"
|
||||||
|
import WhitelistSUPage from "@/admin/whitelistSU/page"
|
||||||
import AuthGuard from "@/components/authGuard"
|
import AuthGuard from "@/components/authGuard"
|
||||||
import HomePage from "@/home"
|
import HomePage from "@/home"
|
||||||
import ClientPage from "@/home/client"
|
import ClientPage from "@/home/client"
|
||||||
@@ -57,6 +59,7 @@ export const router = createBrowserRouter([
|
|||||||
{ index: true, element: <Dashboard /> },
|
{ index: true, element: <Dashboard /> },
|
||||||
{ path: "channels", element: <Channels /> },
|
{ path: "channels", element: <Channels /> },
|
||||||
{ path: "whitelist", element: <Whitelist /> },
|
{ path: "whitelist", element: <Whitelist /> },
|
||||||
|
{ path: "whitelistSU", element: <WhitelistSUPage /> },
|
||||||
{ path: "funds", element: <Funds /> },
|
{ path: "funds", element: <Funds /> },
|
||||||
{
|
{
|
||||||
path: "product",
|
path: "product",
|
||||||
@@ -65,6 +68,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "short", element: <ShortPage /> },
|
{ path: "short", element: <ShortPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ path: "cashOut", element: <CashOutPage /> },
|
||||||
{ path: "orders", element: <OrdersPage /> },
|
{ path: "orders", element: <OrdersPage /> },
|
||||||
{ path: "refundOrders", element: <RefundOrdersPage /> },
|
{ path: "refundOrders", element: <RefundOrdersPage /> },
|
||||||
{ path: "httpRecharge", element: <HttpRechargePage /> },
|
{ path: "httpRecharge", element: <HttpRechargePage /> },
|
||||||
|
|||||||
Reference in New Issue
Block a user