补充个人中心下的页面

This commit is contained in:
Eamon
2026-08-31 14:49:28 +08:00
parent b1f99b51e2
commit 3072b97bcf
59 changed files with 4122 additions and 970 deletions

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

View 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: "",
},
]

View File

@@ -10,6 +10,9 @@ import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import type { ChannelRecord } from "@/lib/models/channel"
import { INITIAL_CHANNELS } from "./mock"
// TODO: 后端就绪后替换为 fetchChannelPage(见 src/api/channel.ts)
const filterSchema = z.object({
expired_at_end: z.string().optional(),
@@ -21,61 +24,8 @@ const filterSchema = z.object({
type FormValues = z.infer<typeof filterSchema>
const initialData: ChannelRecord[] = [
{
id: "1",
startAt: "2025.04.09",
product: "极光IP",
plan: "测试卡(带宽)",
account: "ft8765",
password: "765修改",
ip: "甘肃-兰州125.75.111.127",
server: "p3.125.74.129.38.sdip.cc",
connections: 1,
expiredAt: "2025.04.09",
remaining: "已过期",
online: "查看",
download: "下载",
status: "",
},
{
id: "2",
startAt: "2025.04.09",
product: "极光IP",
plan: "测试卡(带宽)",
account: "ft8765",
password: "765修改",
ip: "辽宁-辽阳123.190.161.192",
server: "p16.cqdwx4.sdip.cc",
connections: 1,
expiredAt: "2025.04.09",
remaining: "0天00时37分",
online: "查看",
download: "下载",
status: "",
},
{
id: "3",
startAt: "2025.04.09",
product: "极光IP",
plan: "静态-项目-20M-月卡",
account: "ft8765",
password: "765修改",
ip: "黑龙江-鸡西100.100.4.249",
server: "p37.sdlw2.sdip.cc",
connections: 1,
expiredAt: "2025.04.09",
remaining: "0天00时37分",
online: "查看",
download: "下载",
status: "",
},
]
export default function ChannelsPage() {
const [filterValues, setFilterValues] = useState<FormValues>({})
const [status, setStatus] = useState<"load" | "done">("done")
// 行选择状态
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const { control, handleSubmit, reset } = useForm<FormValues>({
@@ -91,10 +41,10 @@ export default function ChannelsPage() {
const filteredData = useMemo(() => {
if (!filterValues || Object.values(filterValues).every(value => !value)) {
return initialData
return INITIAL_CHANNELS
}
return initialData.filter(record => {
return INITIAL_CHANNELS.filter(record => {
if (filterValues.expired_at_end) {
return record.expiredAt <= filterValues.expired_at_end
}
@@ -126,12 +76,8 @@ export default function ChannelsPage() {
}, [filterValues])
const onSubmit = (values: FormValues) => {
setStatus("load")
setTimeout(() => {
setFilterValues(values)
setRowSelection({})
setStatus("done")
}, 200)
}
const onReset = () => {
@@ -149,7 +95,8 @@ export default function ChannelsPage() {
// 批量续费
const handleRenew = () => {
const rows = getSelectedRows()
console.log("续费选中数据:", rows)
// TODO: 后端续费接口就绪后接入(见 src/api/http.ts rebuyOrder)
toast.info(`已选择 ${rows.length} 条数据进行续费`)
}
// 单个退货
@@ -159,7 +106,8 @@ export default function ChannelsPage() {
toast.error("请选择一条数据进行退货")
return
}
console.log("退货数据:", rows[0])
// TODO: 后端退货接口就绪后接入
toast.info(`退货账号:${rows[0].account}`)
}
return (
@@ -257,7 +205,7 @@ export default function ChannelsPage() {
</div>
<DataTable<ChannelRecord>
data={filteredData}
status={status}
status="done"
columns={[
{ accessorKey: "startAt", header: "开通时间" },
{ accessorKey: "product", header: "产品" },

View File

@@ -6,7 +6,6 @@ import {
ClipboardList,
Clock1,
FolderCode,
LayoutDashboard,
PanelLeftCloseIcon,
PanelLeftOpenIcon,
ReceiptJapaneseYenIcon,
@@ -14,6 +13,7 @@ import {
ShoppingBag,
TicketPercent,
UserCheck,
UserRound,
} from "lucide-react"
import { type ReactNode, useState } from "react"
import { Link } from "react-router-dom"
@@ -150,16 +150,12 @@ export function Navbar() {
<TooltipProvider>
<NavItem
href="/admin"
icon={<LayoutDashboard size={20} />}
label="账户总览"
expand={navbar}
/>
<NavItem
href="/admin/whitelist"
icon={<UserCheck size={20} />}
label="白名单"
icon={<UserRound size={20} />}
label="个人中心"
expand={navbar}
/>
<NavTitle label="IP管理" />
<NavItem
href="/admin/channels"
icon={<FolderCode size={20} />}
@@ -172,6 +168,12 @@ export function Navbar() {
label="资金明细"
expand={navbar}
/>
<NavItem
href="/admin/rosorder"
icon={<Router size={20} />}
label="软路由订单"
expand={navbar}
/>
<NavItem
href="/admin/orders"
icon={<ShoppingBag size={20} />}
@@ -181,21 +183,22 @@ export function Navbar() {
<NavItem
href="/admin/refundOrders"
icon={<ClipboardList size={20} />}
label="退货管理"
label="退货订单管理"
expand={navbar}
/>
<NavItem
href="/admin/coupon"
icon={<TicketPercent size={20} />}
label="优惠券"
label="我的优惠券"
expand={navbar}
/>
<NavItem
href="/admin/channels"
href="/admin/cashOut"
icon={<ReceiptJapaneseYenIcon size={20} />}
label="提现管理"
expand={navbar}
/>
<NavTitle label="HTTP管理" />
<NavItem
href="/admin/httpRecharge"
icon={<Archive size={20} />}
@@ -214,18 +217,24 @@ export function Navbar() {
label="长效管理"
expand={navbar}
/>
<NavItem
href="/admin/rosorder"
icon={<Router size={20} />}
label="软路由"
expand={navbar}
/>
<NavItem
href="/admin/record"
icon={<ClipboardClock size={20} />}
label="使用记录"
expand={navbar}
/>
<NavItem
href="/admin/whitelist"
icon={<UserCheck size={20} />}
label="白名单"
expand={navbar}
/>
<NavItem
href="/admin/whitelistSU"
icon={<UserCheck size={20} />}
label="白名单(短效)"
expand={navbar}
/>
</TooltipProvider>
</section>
</nav>
@@ -287,3 +296,31 @@ function NavItem(props: {
</Tooltip>
)
}
function NavTitle(props: { label: string }) {
return (
<p
className={cn(
`transition-[height] duration-300 ease-in-out`,
`text-sm text-gray-500 whitespace-nowrap flex items-center relative`,
`group-data-[expand=true]:h-9`,
`group-data-[expand=false]:h-4`,
)}
>
<span
className={cn(
`transition-opacity duration-200 ease-in-out absolute mx-4`,
`group-data-[expand=true]:delay-100 group-data-[expand=true]:opacity-100 group-data-[expand=false]:opacity-0`,
)}
>
{props.label}
</span>
<span
className={cn(
`transition-opacity duration-200 ease-in-out absolute w-full border-b block`,
`group-data-[expand=false]:delay-100 group-data-[expand=false]:opacity-100 group-data-[expand=true]:opacity-0`,
)}
></span>
</p>
)
}

View File

@@ -1,5 +1,220 @@
import { CheckCircle, Clock, Gift, Tag, Ticket, XCircle } from "lucide-react"
import Page from "@/components/page"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
// TODO: 优惠券接口就绪后接入(见 src/api/coupon.ts fetchCouponPage 等)
interface Coupon {
couponType: "Discount" | "Minus"
couponValue: number
name: string
allowMinAmount: number
}
interface UserCouponModel {
coupon: Coupon
orgin: {
startTime: string
endTime: string
remark: string
}
isUsed: boolean
}
// Mock 数据
const mockCoupons: UserCouponModel[] = [
{
coupon: {
couponType: "Discount",
couponValue: 8,
name: "八折优惠券",
allowMinAmount: 100,
},
orgin: {
startTime: "2026-01-01",
endTime: "2026-12-31",
remark: "新用户注册赠送",
},
isUsed: false,
},
{
coupon: {
couponType: "Minus",
couponValue: 50,
name: "满减优惠券",
allowMinAmount: 200,
},
orgin: {
startTime: "2026-06-01",
endTime: "2026-09-30",
remark: "活动期间领取",
},
isUsed: false,
},
{
coupon: {
couponType: "Discount",
couponValue: 9,
name: "九折优惠券",
allowMinAmount: 0,
},
orgin: {
startTime: "2026-08-01",
endTime: "2026-10-01",
remark: "VIP专享",
},
isUsed: true,
},
{
coupon: {
couponType: "Minus",
couponValue: 20,
name: "通用优惠券",
allowMinAmount: 50,
},
orgin: {
startTime: "2026-07-01",
endTime: "2026-08-31",
remark: "签到领取",
},
isUsed: false,
},
]
export default function CouponPage() {
return <Page>优惠券</Page>
const availableCoupons = mockCoupons.filter(item => !item.isUsed)
const usedCoupons = mockCoupons.filter(item => item.isUsed)
const renderCoupon = (item: UserCouponModel) => {
const isDiscount = item.coupon.couponType === "Discount"
const isUsed = item.isUsed
return (
<Card
key={`${item.coupon.name}-${item.orgin.startTime}`}
className={`relative overflow-hidden transition-shadow hover:shadow-md ${isUsed ? "opacity-60" : ""}`}
>
<CardContent className="p-4">
{/* 优惠券左侧 - 面额 */}
<div className="flex items-start gap-4">
<div className="flex min-w-20 flex-col items-center justify-center rounded-lg bg-primary/10 p-3">
{isDiscount ? (
<>
<span className="text-2xl font-bold text-primary">
{item.coupon.couponValue}
</span>
<span className="text-xs text-muted-foreground">折</span>
</>
) : (
<>
<span className="text-xs text-muted-foreground">¥</span>
<span className="text-2xl font-bold text-primary">
{item.coupon.couponValue}
</span>
</>
)}
</div>
{/* 优惠券右侧 - 详情 */}
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{item.coupon.name}</h4>
{isUsed ? (
<Badge variant="secondary" className="text-xs">
<CheckCircle className="mr-1 h-3 w-3" />
已使用
</Badge>
) : (
<Badge variant="default" className="text-xs">
<Ticket className="mr-1 h-3 w-3" />
可使用
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<Tag className="h-3 w-3" />
{item.coupon.allowMinAmount > 0
? `满${item.coupon.allowMinAmount}元可用`
: "无限制"}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{new Date(item.orgin.startTime).toLocaleDateString()} -{" "}
{new Date(item.orgin.endTime).toLocaleDateString()}
</span>
<span className="flex items-center gap-1">
<Gift className="h-3 w-3" />
{item.orgin.remark}
</span>
</div>
</div>
</div>
{/* 已使用水印效果 */}
{isUsed && (
<div className="absolute right-4 top-4">
<XCircle className="h-6 w-6 text-muted-foreground/50" />
</div>
)}
</CardContent>
</Card>
)
}
return (
<Page>
<div className="bg-white rounded-lg p-4">
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">我的优惠券</h3>
<Badge variant="secondary" className="text-sm">
共 {mockCoupons.length} 张
</Badge>
</div>
<Tabs defaultValue="available" className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="available">
可使用 ({availableCoupons.length})
</TabsTrigger>
<TabsTrigger value="used">
已使用 ({usedCoupons.length})
</TabsTrigger>
</TabsList>
<TabsContent value="available" className="mt-4">
{availableCoupons.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Ticket className="mb-4 h-12 w-12 opacity-50" />
<p>暂无可用优惠券</p>
<p className="text-sm">快去参与活动领取吧</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{availableCoupons.map(renderCoupon)}
</div>
)}
</TabsContent>
<TabsContent value="used" className="mt-4">
{usedCoupons.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<CheckCircle className="mb-4 h-12 w-12 opacity-50" />
<p>暂无已使用的优惠券</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{usedCoupons.map(renderCoupon)}
</div>
)}
</TabsContent>
</Tabs>
</div>
</div>
</Page>
)
}

View 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,
}

View File

@@ -18,70 +18,30 @@ import b11 from "../assets/b11.webp"
import b12 from "../assets/b12.webp"
import banner from "../assets/banner.webp"
import banner2 from "../assets/banner2.webp"
import {
MOCK_ACCOUNT_INFO,
MOCK_BALANCE,
MOCK_CONSUMPTION,
MOCK_IP_STATS,
MOCK_RECENT_ORDERS,
} from "./mock"
export default function DashboardPage() {
// ========== 数据定义 ==========
const balanceData = {
balance: 68.0,
hCoin: 68.0,
}
const accountInfo = {
phone: "191****1234",
qq: "123456123",
wechat: "123456123",
email: "123456123@qq.com",
taobao: "123456123",
}
const recentOrders = [
{
date: "2025.04.01",
product: "极光IP",
plan: "天卡(尊享)",
price: 3.0,
connections: 1,
account: "jx1234",
orderAmount: 3.0,
paidAmount: 3.0,
},
{
date: "2025.04.01",
product: "极光IP",
plan: "天卡(尊享)",
price: 3.0,
connections: 1,
account: "jx1234",
orderAmount: 3.0,
paidAmount: 3.0,
},
]
// 设计稿数据 - 全部显示30
const consumptionData = {
today: 30.0,
month: 30.0,
todayRefund: 30.0,
year: 30.0,
todayRecharge: 30.0,
monthRecharge: 30.0,
monthRefund: 30.0,
}
const ipAccountData = {
using: 30,
total: 50,
expired: 20,
yearConsumption: 30,
}
// ========== 数据定义(待接口就绪后改为 useEffect 拉取) ==========
const balanceData = MOCK_BALANCE
const accountInfo = MOCK_ACCOUNT_INFO
const recentOrders = MOCK_RECENT_ORDERS
const consumptionData = MOCK_CONSUMPTION
const ipAccountData = MOCK_IP_STATS
const [editDialogOpen, setEditDialogOpen] = useState(false)
const handleSaveInfo = (data: EditInfoValues) => {
console.log("保存的信息:", data)
// TODO: 接入 src/api/user.ts updateUserProfile
void data
}
// ========== 渲染 ==========
return (
<Page
className={cn(
@@ -226,7 +186,7 @@ export default function DashboardPage() {
</div>
</div>
{/* ====== 第二行:最近订单(占左侧3列) ====== */}
{/* 可以优化掉不要 */}
<div className="md:row-start-2 md:col-start-1 md:col-span-3 bg-white rounded-lg p-4 flex flex-col min-h-0">
<h3 className="font-semibold text-lg mb-4 shrink-0">最近订单</h3>
<div className="flex-1 overflow-auto min-h-0">
@@ -259,8 +219,7 @@ export default function DashboardPage() {
</div>
</div>
{/* ====== 第三、四行:消费信息 + IP账号 ====== */}
{/* 消费信息 - 占左侧3列,跨2行 */}
{/* ====== 消费信息 + IP账号 ====== */}
<div className="md:col-start-1 md:row-start-3 md:col-span-3 md:row-span-2 bg-white rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-lg">消费信息</h3>
@@ -373,7 +332,7 @@ export default function DashboardPage() {
</div>
</div>
{/* ====== IP账号(占右侧1列,跨2行) ====== */}
{/* ====== IP账号 ====== */}
<div className="md:col-start-4 md:row-start-3 md:row-span-2 bg-white rounded-lg p-4">
<h3 className="font-semibold text-lg mb-6">IP账号</h3>
<div className="grid grid-cols-3 gap-4 text-center">

View File

@@ -1,6 +1,8 @@
import DataTable from "@/components/data-table"
import Page from "@/components/page"
export default function FundsPage() {
return (
<Page>

View File

@@ -1,11 +1,14 @@
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import type { HttpRechargeItem } from "@/lib/models/httpRecharge"
export default function HttpRechargePage() {
return (
<Page>
<div className="bg-white rounded-lg p-4">
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
<DataTable
<DataTable<HttpRechargeItem>
data={[]}
status="done"
columns={[
@@ -27,6 +30,7 @@ export default function HttpRechargePage() {
},
]}
/>
</div>
</Page>
)
}

View File

@@ -1,6 +1,6 @@
import type { ColumnDef } from "@tanstack/react-table"
import { SearchIcon } from "lucide-react"
import { useMemo, useState } from "react"
import AccountCell from "@/components/composites/accountCell"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Button } from "@/components/ui/button"
@@ -12,49 +12,19 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useOrderFilters } from "@/lib/hooks/useOrderFilters"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
cutAccount,
DEFAULT_FILTERS,
formatDate,
formatMoney,
formatOrderType,
ORDER_TYPE_LABELS,
type OrderFilters,
type OrderItem,
PACKAGE_OPTIONS,
totalConnections,
} from "@/lib/models/order"
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
import { MOCK_ORDERS, MOCK_PRODUCTS } from "../mock/orders"
function formatMoney(value: number): string {
return value.toFixed(2)
}
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}.${month}.${day}`
}
function AccountCell({ accounts }: { accounts: string }) {
const isCut = accounts.length > 15
if (!isCut) return <span className="truncate">{accounts}</span>
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
</Tooltip>
)
}
// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchOrderPage 返回的分页数据
const columns: ColumnDef<OrderItem>[] = [
{
@@ -111,69 +81,18 @@ const columns: ColumnDef<OrderItem>[] = [
},
]
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
const keyword = filters.keyword.trim()
return list.filter(order => {
if (
filters.orderType !== "0" &&
order.OrderType !== Number(filters.orderType)
) {
return false
}
if (
filters.productId !== "0" &&
order.ProductId !== Number(filters.productId)
) {
return false
}
if (
filters.packageName !== "0" &&
order.PackageName !== filters.packageName
) {
return false
}
if (
keyword &&
!order.OrderNo.includes(keyword) &&
!order.Accounts.includes(keyword)
) {
return false
}
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
return false
}
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
return false
}
return true
})
}
export default function OrdersPage() {
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
const [appliedFilters, setAppliedFilters] =
useState<OrderFilters>(DEFAULT_FILTERS)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const filtered = useMemo(
() => matchOrders(MOCK_ORDERS, appliedFilters),
[appliedFilters],
)
const paged = useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
)
const patch = (partial: Partial<OrderFilters>) =>
setFilters({ ...filters, ...partial })
const handleSearch = () => {
setAppliedFilters(filters)
setPage(1)
}
const {
filters,
patch,
handleSearch,
page,
setPage,
pageSize,
handleSizeChange,
filtered,
paged,
} = useOrderFilters(MOCK_ORDERS)
return (
<Page>
@@ -278,10 +197,7 @@ export default function OrdersPage() {
size: pageSize,
total: filtered.length,
onPageChange: setPage,
onSizeChange: size => {
setPageSize(size)
setPage(1)
},
onSizeChange: handleSizeChange,
}}
/>
</div>

View File

@@ -16,6 +16,9 @@ import {
} from "@/lib/models/record"
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
// TODO: 后端使用记录接口就绪后,将 mock 替换为 src/api/record.ts 的
// fetchHttpUsedSummary / fetchHttpUsedLogs(服务端分页)
const columns: ColumnDef<HttpUsedLogItem>[] = [
{
accessorKey: "packType",

View File

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

View File

@@ -1,6 +1,6 @@
import type { ColumnDef } from "@tanstack/react-table"
import { SearchIcon } from "lucide-react"
import { useMemo, useState } from "react"
import AccountCell from "@/components/composites/accountCell"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Button } from "@/components/ui/button"
@@ -12,48 +12,17 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useOrderFilters } from "@/lib/hooks/useOrderFilters"
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip"
import {
cutAccount,
DEFAULT_FILTERS,
formatOrderType,
type OrderFilters,
formatDate,
formatMoney,
type OrderItem,
PACKAGE_OPTIONS,
totalConnections,
} from "@/lib/models/order"
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
import { MOCK_ORDERS, MOCK_PRODUCTS } from "../mock/orders"
function formatMoney(value: number): string {
return value.toFixed(2)
}
function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}.${month}.${day}`
}
function AccountCell({ accounts }: { accounts: string }) {
const isCut = accounts.length > 15
if (!isCut) return <span className="truncate">{accounts}</span>
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
</TooltipTrigger>
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
</Tooltip>
)
}
// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchRefundOrders 返回的分页数据
const columns: ColumnDef<OrderItem>[] = [
{
@@ -65,11 +34,6 @@ const columns: ColumnDef<OrderItem>[] = [
accessorKey: "OrderNo",
header: "订单编号",
},
{
accessorKey: "OrderType",
header: "类型",
cell: ({ row }) => formatOrderType(row.original.OrderType),
},
{
accessorKey: "ProductName",
header: "产品",
@@ -110,69 +74,18 @@ const columns: ColumnDef<OrderItem>[] = [
},
]
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
const keyword = filters.keyword.trim()
return list.filter(order => {
if (
filters.orderType !== "0" &&
order.OrderType !== Number(filters.orderType)
) {
return false
}
if (
filters.productId !== "0" &&
order.ProductId !== Number(filters.productId)
) {
return false
}
if (
filters.packageName !== "0" &&
order.PackageName !== filters.packageName
) {
return false
}
if (
keyword &&
!order.OrderNo.includes(keyword) &&
!order.Accounts.includes(keyword)
) {
return false
}
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
return false
}
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
return false
}
return true
})
}
export default function RefundOrdersPage() {
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
const [appliedFilters, setAppliedFilters] =
useState<OrderFilters>(DEFAULT_FILTERS)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const filtered = useMemo(
() => matchOrders(MOCK_ORDERS, appliedFilters),
[appliedFilters],
)
const paged = useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
)
const patch = (partial: Partial<OrderFilters>) =>
setFilters({ ...filters, ...partial })
const handleSearch = () => {
setAppliedFilters(filters)
setPage(1)
}
const {
filters,
patch,
handleSearch,
page,
setPage,
pageSize,
handleSizeChange,
filtered,
paged,
} = useOrderFilters(MOCK_ORDERS)
return (
<Page>
@@ -250,7 +163,7 @@ export default function RefundOrdersPage() {
</div>
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
<h3 className="font-semibold text-lg">订单管理</h3>
<h3 className="font-semibold text-lg">退款订单管理</h3>
<DataTable
data={paged}
status="done"
@@ -260,10 +173,7 @@ export default function RefundOrdersPage() {
size: pageSize,
total: filtered.length,
onPageChange: setPage,
onSizeChange: size => {
setPageSize(size)
setPage(1)
},
onSizeChange: handleSizeChange,
}}
/>
</div>

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

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

View File

@@ -1,5 +1,440 @@
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"
import {
fetchCxOrders,
fetchCxPackages,
fetchLangIpList,
fetchLangIps,
fetchSwitchList,
} from "@/api/http"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import type {
CxOrder,
CxPackage,
LangIpItem,
SwitchRecord,
} from "@/lib/models/package"
import {
formatAbroad,
formatIpState,
formatOrderTypeLabel,
LONG_ORDER_TYPE_LABELS,
parseExpired,
} from "@/lib/models/package"
import { downloadTextFile, estimateTotal } from "@/lib/utils"
import LongRenewDialog, { type LongRenewSource } from "./long-renew-dialog"
import LongSwitchDialog from "./long-switch-dialog"
const PAGE_SIZE = 50
export default function LongPage() {
return <Page>长效管理</Page>
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState("ips")
// 已开通长效
const [ipList, setIpList] = useState<LangIpItem[]>([])
const [ipStatus, setIpStatus] = useState<"load" | "done" | "fail">("load")
const [ipPage, setIpPage] = useState(1)
const [ipTotal, setIpTotal] = useState(0)
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
// 全部套餐
const [packages, setPackages] = useState<CxPackage[]>([])
const [packageStatus, setPackageStatus] = useState<"load" | "done" | "fail">(
"load",
)
// 购买记录
const [orders, setOrders] = useState<CxOrder[]>([])
const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">(
"load",
)
// IP切换记录
const [switchList, setSwitchList] = useState<SwitchRecord[]>([])
const [switchStatus, setSwitchStatus] = useState<"load" | "done" | "fail">(
"load",
)
const [switchPage, setSwitchPage] = useState(1)
const [switchTotal, setSwitchTotal] = useState(0)
// 弹窗
const [renewSource, setRenewSource] = useState<LongRenewSource | null>(null)
const [switchTarget, setSwitchTarget] = useState<{
ip: LangIpItem
pop: 0 | 1
} | null>(null)
const loadIps = useCallback(async (page: number) => {
setIpStatus("load")
const res = await fetchLangIpList({
page,
limit: PAGE_SIZE,
packId: "",
isAbroad: "",
isExpired: 0,
state: -1,
ip: "",
groupId: "",
gameId: "",
cityCode: "",
orderBy: 0,
})
if (!res.success) {
setIpStatus("fail")
toast.error(res.message)
return
}
setIpList(res.data.items)
setIpTotal(
estimateTotal(
res.data.pager.totalCount,
res.data.pager.currentPage || page,
PAGE_SIZE,
res.data.items.length,
),
)
setIpStatus("done")
}, [])
const loadPackages = useCallback(async () => {
setPackageStatus("load")
const res = await fetchCxPackages()
if (!res.success) {
setPackageStatus("fail")
return
}
setPackages(res.data)
setPackageStatus("done")
}, [])
const loadOrders = useCallback(async () => {
setOrderStatus("load")
const res = await fetchCxOrders()
if (!res.success) {
setOrderStatus("fail")
return
}
setOrders(res.data)
setOrderStatus("done")
}, [])
const loadSwitch = useCallback(async (page: number) => {
setSwitchStatus("load")
const res = await fetchSwitchList({
page,
limit: PAGE_SIZE,
})
if (!res.success) {
setSwitchStatus("fail")
toast.error(res.message)
return
}
setSwitchList(res.data.items)
setSwitchTotal(
estimateTotal(
res.data.pager.totalCount,
res.data.pager.currentPage || page,
PAGE_SIZE,
res.data.items.length,
),
)
setSwitchStatus("done")
}, [])
useEffect(() => {
void loadIps(1)
}, [loadIps])
const handleTabChange = (value: string) => {
setActiveTab(value)
if (value === "packages" && !packages.length) void loadPackages()
if (value === "orders" && !orders.length) void loadOrders()
if (value === "switch" && !switchList.length) void loadSwitch(1)
}
const handlePageChange = (page: number) => {
setIpPage(page)
setRowSelection({})
void loadIps(page)
}
const handleSwitchPageChange = (page: number) => {
setSwitchPage(page)
void loadSwitch(page)
}
const handleExtract = async () => {
const ids = Object.keys(rowSelection)
if (ids.length === 0) {
toast.error("请选择IP!")
return
}
const res = await fetchLangIps(ids.join(","))
if (!res.success) {
toast.error(res.message)
return
}
if (res.data.length === 0) {
toast.error("提取失败,请稍后重试")
return
}
const content = res.data
.map(
ip =>
`${ip.ip}|${ip.httpPort}|${ip.socks5Port}|${ip.username}|${ip.password}`,
)
.join("\n")
downloadTextFile("ip.txt", content)
toast.success("提取成功,已开始下载")
}
const ipColumns = useMemo<ColumnDef<LangIpItem>[]>(
() => [
{
accessorKey: "isRelayed",
header: "模式",
cell: ({ row }) => (row.original.isRelayed ? "中继" : "直连"),
},
{ accessorKey: "ip", header: "节点入口IP" },
{ accessorKey: "realIp", header: "隧道出口IP" },
{
id: "ports",
header: "HTTP/SK5端口",
cell: ({ row }) =>
`${row.original.httpPort}/${row.original.socks5Port}`,
},
{ accessorKey: "area", header: "归属地" },
{
id: "bandwidth",
header: "带宽",
cell: ({ row }) =>
`${row.original.bandwidth}M/${row.original.lineType}`,
},
{ accessorKey: "shareType", header: "线路" },
{
accessorKey: "state",
header: "状态",
cell: ({ row }) => formatIpState(row.original.state),
},
{
id: "account",
header: "账号/密码",
cell: ({ row }) => `${row.original.username}/${row.original.password}`,
},
{ accessorKey: "expireTime", header: "过期时间" },
{
accessorKey: "isExpired",
header: "是否过期",
cell: ({ row }) =>
parseExpired(row.original.isExpired) ? "已过期" : "未过期",
},
{
id: "actions",
header: "操作",
cell: ({ row }) => {
const ip = row.original
return (
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
onClick={() => setRenewSource({ kind: "ip", ip })}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
续费
</Button>
{ip.isAbroad === 1 && ip.isRelayed === 1 && (
<Button
size="sm"
variant="outline"
onClick={() => setSwitchTarget({ ip, pop: 1 })}
>
切换入口
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => setSwitchTarget({ ip, pop: 0 })}
>
切换节点
</Button>
</div>
)
},
},
],
[],
)
const packageColumns = useMemo<ColumnDef<CxPackage>[]>(
() => [
{
accessorKey: "isAbroad",
header: "套餐类型",
cell: ({ row }) => formatAbroad(row.original.isAbroad),
},
{ accessorKey: "shareType", header: "模式" },
{ accessorKey: "ipAmount", header: "可用IP数量(个)" },
{ accessorKey: "expireTime", header: "过期时间" },
{
id: "actions",
header: "续费",
cell: ({ row }) => (
<Button
size="sm"
onClick={() =>
setRenewSource({ kind: "package", pkg: row.original })
}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
续费
</Button>
),
},
],
[],
)
const orderColumns = useMemo<ColumnDef<CxOrder>[]>(
() => [
{ accessorKey: "order_no", header: "订单号" },
{
accessorKey: "order_type",
header: "订单类型",
cell: ({ row }) =>
formatOrderTypeLabel(LONG_ORDER_TYPE_LABELS, row.original.order_type),
},
{ accessorKey: "game_type", header: "游戏类型" },
{ accessorKey: "ip_count", header: "IP数量" },
{ accessorKey: "money", header: "实付金额" },
{ accessorKey: "create_time", header: "支付时间" },
{
id: "rebuy",
header: "再次购买",
cell: () => (
<Button
size="sm"
variant="outline"
onClick={() => navigate("/product/http")}
>
再次购买
</Button>
),
},
],
[navigate],
)
const switchColumns = useMemo<ColumnDef<SwitchRecord>[]>(
() => [
{ accessorKey: "oldIp", header: "原IP" },
{ accessorKey: "oldArea", header: "原属地" },
{ accessorKey: "newIp", header: "切换后IP" },
{ accessorKey: "newArea", header: "切换后属地" },
{ accessorKey: "reason", header: "切换方式" },
{ accessorKey: "createTime", header: "切换时间" },
],
[],
)
return (
<Page>
<div className="bg-white rounded-lg p-4">
<h3 className="font-semibold text-lg mb-4">长效套餐</h3>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="ips">已开通长效</TabsTrigger>
<TabsTrigger value="packages">全部套餐</TabsTrigger>
<TabsTrigger value="orders">购买记录</TabsTrigger>
<TabsTrigger value="switch">IP切换记录</TabsTrigger>
</TabsList>
<TabsContent value="ips" className="pt-4 flex flex-col gap-3">
<div className="flex items-center gap-4 flex-wrap">
<Button
onClick={() => void handleExtract()}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
批量提取IP
</Button>
<span className="text-xs text-gray-500">
格式:IP|http端口|socks5端口|账号|密码
</span>
</div>
<DataTable
data={ipList}
status={ipStatus}
columns={ipColumns}
enableSelection
rowSelection={rowSelection}
onSelectionChange={setRowSelection}
getRowId={row => String(row.id)}
pagination={{
page: ipPage - 1,
size: PAGE_SIZE,
total: ipTotal,
sizeOptions: [PAGE_SIZE],
onPageChange: handlePageChange,
}}
/>
</TabsContent>
<TabsContent value="packages" className="pt-4">
<DataTable
data={packages}
status={packageStatus}
columns={packageColumns}
/>
</TabsContent>
<TabsContent value="orders" className="pt-4">
<DataTable
data={orders}
status={orderStatus}
columns={orderColumns}
/>
</TabsContent>
<TabsContent value="switch" className="pt-4">
<DataTable
data={switchList}
status={switchStatus}
columns={switchColumns}
pagination={{
page: switchPage - 1,
size: PAGE_SIZE,
total: switchTotal,
sizeOptions: [PAGE_SIZE],
onPageChange: handleSwitchPageChange,
}}
/>
</TabsContent>
</Tabs>
</div>
<LongRenewDialog
open={renewSource !== null}
source={renewSource}
onClose={() => setRenewSource(null)}
onSuccess={() => {
void loadIps(ipPage)
void loadPackages()
}}
/>
<LongSwitchDialog
open={switchTarget !== null}
ip={switchTarget?.ip ?? null}
pop={switchTarget?.pop ?? 0}
onClose={() => setSwitchTarget(null)}
onSuccess={() => void loadIps(ipPage)}
/>
</Page>
)
}

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

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

View File

@@ -1,5 +1,180 @@
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom"
import { fetchDxOrders, fetchDxPackages, fetchWhiteLimitMax } from "@/api/http"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Button } from "@/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
type DxOrder,
type DxPackage,
formatExpired,
formatOrderTypeLabel,
SHORT_ORDER_TYPE_LABELS,
SHORT_PLAN_VOLUME,
} from "@/lib/models/package"
import ShortApiLink from "./short-api-link"
import ShortRenewDialog from "./short-renew-dialog"
export default function ShortPage() {
return <Page>短效套餐</Page>
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState("packages")
const [packages, setPackages] = useState<DxPackage[]>([])
const [orders, setOrders] = useState<DxOrder[]>([])
const [status, setStatus] = useState<"load" | "done" | "fail">("load")
const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">(
"load",
)
const [whitelist, setWhitelist] = useState(1)
const [renewPackage, setRenewPackage] = useState<DxPackage | null>(null)
const loadPackages = useCallback(async () => {
setStatus("load")
const res = await fetchDxPackages()
if (!res.success) {
setStatus("fail")
return
}
setPackages(res.data)
setStatus("done")
}, [])
const loadOrders = useCallback(async () => {
setOrderStatus("load")
const res = await fetchDxOrders()
if (!res.success) {
setOrderStatus("fail")
return
}
setOrders(res.data)
setOrderStatus("done")
}, [])
useEffect(() => {
void loadPackages()
void fetchWhiteLimitMax().then(res => {
if (res.success && res.data > 0) setWhitelist(res.data)
})
}, [loadPackages])
const handleTabChange = (value: string) => {
setActiveTab(value)
if (value === "orders" && !orders.length) void loadOrders()
}
const packageColumns = useMemo<ColumnDef<DxPackage>[]>(
() => [
{ accessorKey: "name", header: "套餐类型" },
{
accessorKey: "remainingAmount",
header: "当日可用IP数量(个)",
cell: ({ row }) => String(row.original.remainingAmount ?? "-"),
},
{ accessorKey: "durationType", header: "有效时长" },
{ accessorKey: "expireTime", header: "过期时间" },
{
accessorKey: "isExpired",
header: "是否过期",
cell: ({ row }) => formatExpired(row.original.isExpired),
},
{
id: "actions",
header: "续费",
cell: ({ row }) => {
const pkg = row.original
if (pkg.planType === SHORT_PLAN_VOLUME) {
return <span className="text-gray-400">不支持续费</span>
}
return (
<Button
size="sm"
onClick={() => setRenewPackage(pkg)}
className="bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90"
>
续费
</Button>
)
},
},
],
[],
)
const orderColumns = useMemo<ColumnDef<DxOrder>[]>(
() => [
{ accessorKey: "order_no", header: "订单号" },
{
accessorKey: "order_type",
header: "订单类型",
cell: ({ row }) =>
formatOrderTypeLabel(
SHORT_ORDER_TYPE_LABELS,
row.original.order_type,
),
},
{ accessorKey: "package_type", header: "套餐类型" },
{ accessorKey: "durationType", header: "有效时长" },
{ accessorKey: "money", header: "支付金额" },
{ accessorKey: "create_time", header: "支付时间" },
{
id: "rebuy",
header: "再次购买",
cell: () => (
<Button
size="sm"
variant="outline"
onClick={() => navigate("/product/http")}
>
再次购买
</Button>
),
},
],
[navigate],
)
return (
<Page>
<div className="bg-white rounded-lg p-4">
<h3 className="font-semibold text-lg mb-4">短效套餐</h3>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="packages">全部套餐</TabsTrigger>
<TabsTrigger value="orders">购买记录</TabsTrigger>
<TabsTrigger value="extract">提取短效</TabsTrigger>
</TabsList>
<TabsContent value="packages" className="pt-4">
<DataTable
data={packages}
status={status}
columns={packageColumns}
/>
</TabsContent>
<TabsContent value="orders" className="pt-4">
<DataTable
data={orders}
status={orderStatus}
columns={orderColumns}
/>
</TabsContent>
<TabsContent value="extract" className="pt-4">
<ShortApiLink packages={packages} />
</TabsContent>
</Tabs>
</div>
<ShortRenewDialog
open={renewPackage !== null}
pkg={renewPackage}
whitelist={whitelist}
onClose={() => setRenewPackage(null)}
onSuccess={() => void loadPackages()}
/>
</Page>
)
}

View File

@@ -1,5 +1,53 @@
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import type { RosOrderItem } from "@/lib/models/rosorder"
// TODO: 软路由订单接口就绪后接入(见 src/api/rosorder.ts fetchRosOrderList)
export default function RosorderPage() {
return <Page>软路由页面</Page>
return (
<Page>
<div className="bg-white rounded-lg p-4">
<h3 className="font-semibold text-lg">软路由订单列表</h3>
<DataTable<RosOrderItem>
data={[]}
status="done"
columns={[
{
accessorKey: "username",
header: "用户",
},
{
accessorKey: "product_name",
header: "软路由",
},
{
accessorKey: "real_price",
header: "实付",
},
{
accessorKey: "order_no",
header: "订单编号",
},
{
accessorKey: "user",
header: "收货人姓名",
},
{
accessorKey: "phone",
header: "收货人电话",
},
{
accessorKey: "address",
header: "收货人地址",
},
{
accessorKey: "create_time",
header: "购买时间",
},
]}
/>
</div>
</Page>
)
}

View File

@@ -1,5 +1,127 @@
import { Lock, Trash2, Unlock } from "lucide-react"
import { useState } from "react"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import type { WhiteIpItem } from "@/lib/models/whitelist"
// TODO: 后端白名单接口就绪后,将 saveWhiteIp/fetchWhiteList 接入(见 src/api/whitelist.ts)
export default function WhitelistPage() {
return <Page>白名单</Page>
const [ip, setIp] = useState("")
const [list, setList] = useState<WhiteIpItem[]>([])
const handleSave = () => {
const value = ip.trim()
if (!value) return
// TODO: await saveWhiteIp(value)
setList(prev => [
{ id: Date.now(), ip: value, createTime: "", isLocked: "未锁定" },
...prev,
])
setIp("")
}
return (
<Page>
<div className="bg-white rounded-lg p-4">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Input
value={ip}
onChange={e => setIp(e.target.value)}
onKeyDown={e => e.key === "Enter" && handleSave()}
placeholder="请输入IP地址并点击保存白名单"
className="max-w-sm"
/>
<Button onClick={handleSave}>保存</Button>
</div>
<div className="flex items-center gap-3">
<h3 className="font-semibold text-lg">当前白名单列表</h3>
<Badge variant="secondary" className="text-yellow-600">
当前仅允许保留白名单数量:5个
</Badge>
</div>
<DataTable<WhiteIpItem>
data={list}
status="done"
columns={[
{
accessorKey: "ip",
header: "IP地址",
},
{
accessorKey: "createTime",
header: "设置时间",
},
{
accessorKey: "isLocked",
header: "锁定状态",
cell: ({ row }) => {
const isLocked = row.original.isLocked === "已锁定"
return (
<Badge variant={isLocked ? "destructive" : "outline"}>
{row.original.isLocked}
</Badge>
)
},
},
{
id: "actions",
header: "操作",
cell: ({ row }) => {
const item = row.original
// TODO: 接入 lockWhiteIp / deleteWhiteIp
return (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setList(prev =>
prev.map(i =>
i.id === item.id
? {
...i,
isLocked:
i.isLocked === "未锁定"
? "已锁定"
: "未锁定",
}
: i,
),
)
}
>
{item.isLocked === "未锁定" ? (
<Lock className="mr-1 h-3 w-3" />
) : (
<Unlock className="mr-1 h-3 w-3" />
)}
{item.isLocked === "未锁定" ? "锁定" : "解锁"}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() =>
setList(prev => prev.filter(i => i.id !== item.id))
}
>
<Trash2 className="mr-1 h-3 w-3" />
删除
</Button>
</div>
)
},
},
]}
/>
</div>
</div>
</Page>
)
}

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

View File

@@ -70,6 +70,7 @@ async function request<R = undefined>(
Message?: string
Data?: unknown
data?: unknown
TotalCount?: number
}
if (!response.ok) {
return {
@@ -85,6 +86,8 @@ async function request<R = undefined>(
success: true,
data: (json.Data ?? json.data ?? undefined) as R,
headers: responseHeaders,
totalCount:
typeof json.TotalCount === "number" ? json.TotalCount : undefined,
}
}
return {
@@ -168,7 +171,28 @@ async function call<R = undefined>(
data?: unknown,
method: "GET" | "POST" = "POST",
): Promise<ApiResponse<R>> {
const body = data === undefined ? undefined : JSON.stringify(data)
// GET 请求:将 data 序列化为查询串;POST 请求:序列化为 JSON body
let url = endpoint
let body: string | undefined
if (method === "GET") {
const params = new URLSearchParams()
if (data && typeof data === "object") {
for (const [key, value] of Object.entries(
data as Record<string, unknown>,
)) {
if (value === undefined || value === null || value === "") continue
if (Array.isArray(value)) {
params.append(key, value.join(","))
} else {
params.append(key, String(value))
}
}
}
const query = params.toString()
if (query) url = `${endpoint}${endpoint.includes("?") ? "&" : "?"}${query}`
} else {
body = data === undefined ? undefined : JSON.stringify(data)
}
const run = async (): Promise<ApiResponse<R>> => {
const maxAttempts = mode === "device" ? 2 : 1
@@ -202,7 +226,7 @@ async function call<R = undefined>(
auth = `Bearer ${token}`
}
const resp = await request<R>(endpoint, { method, body, auth })
const resp = await request<R>(url, { method, body, auth })
if (!resp.success && resp.status === 401) {
if (mode === "user") {

25
src/api/cashOut.ts Normal file
View 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
View 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
View 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
View 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",
)
}

View File

@@ -6,6 +6,22 @@ import type {
GameOrderData,
HttpOrderInfo,
} from "@/lib/models/http"
import type {
ChangeIpData,
CxOrder,
CxPackage,
DxOrder,
DxPackage,
LangIpExtract,
LangIpItem,
LangIpListResult,
OrderActionResult,
PackageOrderInfo,
Pager,
SwitchListResult,
SwitchRecord,
UserApiToken,
} from "@/lib/models/package"
export const PHP_API_BASE_URL =
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
@@ -14,18 +30,27 @@ const DEFAULT_TIMEOUT = 30_000
type RawHttpResponse = Record<string, unknown>
async function postJson<T = RawHttpResponse>(
// ==================== 统一 PHP 请求底层 ====================
// 所有 /http/* 接口都带 cookie 走同一出口:超时、错误归一化在此层处理,
// 各接口函数只关心业务数据,不再各自实现 fetch。
// credentials 仅对需要登录态的 POST 开启;公开脚本 GET 接口(如线路表)
// 不带 cookie,避免跨域时服务端 Access-Control-Allow-Origin 配置不匹配。
export async function phpFetch<T = RawHttpResponse>(
method: "GET" | "POST",
path: string,
body: unknown,
body?: unknown,
options: { credentials?: RequestCredentials } = {},
): Promise<ApiResponse<T>> {
const { credentials = "same-origin" } = options
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
try {
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body),
method,
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
credentials,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
if (!response.ok) {
@@ -55,7 +80,25 @@ async function postJson<T = RawHttpResponse>(
}
}
function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
export async function phpPost<T = RawHttpResponse>(
path: string,
body?: unknown,
): Promise<ApiResponse<T>> {
return phpFetch<T>("POST", path, body, { credentials: "include" })
}
export async function phpGet<T = RawHttpResponse>(
path: string,
): Promise<ApiResponse<T>> {
return phpFetch<T>("GET", path)
}
// ==================== 响应解析工具 ====================
// 老 PHP 接口习惯把业务数据包在 d 字段里
export function unwrapD(
raw: RawHttpResponse | undefined,
): Record<string, unknown> {
const d = raw?.d
if (d && typeof d === "object" && !Array.isArray(d)) {
return d as Record<string, unknown>
@@ -63,43 +106,71 @@ function unwrapD(raw: RawHttpResponse | undefined): Record<string, unknown> {
return {}
}
export function isArray<T>(value: unknown): value is T[] {
return Array.isArray(value)
}
export function toNumber(value: unknown): number {
const n = Number(value)
return Number.isFinite(n) ? n : 0
}
// 解析服务端分页信息,缺失字段兜底
export function parsePager(raw: unknown, itemCount: number): Pager {
const p = (raw ?? {}) as Record<string, unknown>
return {
currentPage: toNumber(p.currentPage) || 1,
pageSize: toNumber(p.pageSize) || itemCount,
totalCount: toNumber(p.totalCount),
totalPage: toNumber(p.totalPage),
}
}
// ==================== 游戏产品 ====================
export async function fetchGameCities(
game: GameOrderData,
): Promise<ApiResponse<GameCity[]>> {
const res = await postJson<RawHttpResponse>("/http/product/city", game)
const res = await phpPost<RawHttpResponse>("/http/product/city", game)
if (!res.success) return res
const cities = unwrapD(res.data).cities
return {
success: true,
data: Array.isArray(cities) ? (cities as GameCity[]) : [],
data: isArray<GameCity>(cities) ? cities : [],
}
}
export async function fetchGameList(
game: GameOrderData,
): Promise<ApiResponse<GameItem[]>> {
const res = await postJson<RawHttpResponse>("/http/product/game", game)
const res = await phpPost<RawHttpResponse>("/http/product/game", game)
if (!res.success) return res
const games = unwrapD(res.data).games
return {
success: true,
data: Array.isArray(games) ? (games as GameItem[]) : [],
data: isArray<GameItem>(games) ? games : [],
}
}
export async function fetchGameLineCount(
game: GameOrderData,
): Promise<ApiResponse<number>> {
const res = await postJson<RawHttpResponse>("/http/product/linecount", game)
const res = await phpPost<RawHttpResponse>("/http/product/linecount", game)
if (!res.success) return res
const count = unwrapD(res.data).count
return { success: true, data: Number(count) || 0 }
return { success: true, data: toNumber(count) }
}
export async function calcGamePrice(
orderInfo: HttpOrderInfo,
): Promise<ApiResponse<number>> {
const res = await postJson<RawHttpResponse>(
return calcOrderPrice(orderInfo as unknown as PackageOrderInfo)
}
export async function calcOrderPrice(
orderInfo: PackageOrderInfo,
): Promise<ApiResponse<number>> {
const res = await phpPost<RawHttpResponse>(
"/http/product/calc_price",
orderInfo,
)
@@ -113,10 +184,12 @@ export async function calcGamePrice(
}
}
// ==================== 订单 ====================
export async function createHttpOrder(
orderInfo: HttpOrderInfo,
): Promise<ApiResponse<CreateOrderResult>> {
const res = await postJson<RawHttpResponse>("/http/order/create_order", {
const res = await phpPost<RawHttpResponse>("/http/order/create_order", {
cookie: document.cookie,
order_info: orderInfo,
})
@@ -124,23 +197,187 @@ export async function createHttpOrder(
return {
success: true,
data: {
code: Number(res.data?.code) || 0,
code: toNumber(res.data?.code),
msg: typeof res.data?.msg === "string" ? res.data.msg : undefined,
data: typeof res.data?.data === "string" ? res.data.data : undefined,
},
}
}
export async function fetchHttpBalance(): Promise<ApiResponse<number>> {
const res = await postJson<RawHttpResponse | number>(
"/http/user/get_balance",
{
function parseOrderAction(raw: RawHttpResponse): OrderActionResult {
return {
code: toNumber(raw.code),
msg: typeof raw.msg === "string" ? raw.msg : undefined,
}
}
export async function rebuyOrder(
orderInfo: PackageOrderInfo,
): Promise<ApiResponse<OrderActionResult>> {
const res = await phpPost<RawHttpResponse>("/http/order/rebuy_order", {
cookie: document.cookie,
},
order_info: { ...orderInfo, renew: 1 },
})
if (!res.success) return res
return { success: true, data: parseOrderAction(res.data ?? {}) }
}
export async function changeIpOrder(
orderInfo: Omit<PackageOrderInfo, "data"> & { data: ChangeIpData },
): Promise<ApiResponse<OrderActionResult>> {
const res = await phpPost<RawHttpResponse>("/http/order/change_ip_order", {
cookie: document.cookie,
order_info: orderInfo,
})
if (!res.success) return res
return { success: true, data: parseOrderAction(res.data ?? {}) }
}
// ==================== 用户信息 ====================
export async function fetchHttpBalance(): Promise<ApiResponse<number>> {
const res = await phpPost<RawHttpResponse | number>(
"/http/user/get_balance",
{ cookie: document.cookie },
)
if (!res.success) return res
if (typeof res.data === "number") return { success: true, data: res.data }
const raw = res.data ?? {}
const balance = raw.d ?? raw.data ?? raw.balance
return { success: true, data: Number(balance) || 0 }
return { success: true, data: toNumber(balance) }
}
export async function fetchUserApiToken(): Promise<ApiResponse<UserApiToken>> {
const res = await phpPost<RawHttpResponse>("/http/user/get_user_token", {
cookie: document.cookie,
})
if (!res.success) return res
const raw = (res.data?.data ?? res.data ?? {}) as Record<string, unknown>
return {
success: true,
data: {
uid: raw.uid as UserApiToken["uid"],
token: typeof raw.token === "string" ? raw.token : "",
},
}
}
export async function fetchWhiteLimitMax(): Promise<ApiResponse<number>> {
const res = await phpPost<RawHttpResponse>("/http/user/white_limit_get_su", {
cookie: document.cookie,
})
if (!res.success) return res
const raw = (res.data?.data ?? res.data ?? {}) as Record<string, unknown>
// 返回值可能是 { su: n } / { data: n },也可能直接是数字,逐层尝试
return { success: true, data: toNumber(raw.su ?? raw.data ?? raw) }
}
// ==================== 短效套餐 ====================
export async function fetchDxPackages(): Promise<ApiResponse<DxPackage[]>> {
const res = await phpPost<RawHttpResponse>("/http/user/dx_package", {
cookie: document.cookie,
})
if (!res.success) return res
const list = res.data?.data ?? res.data
return {
success: true,
data: isArray<DxPackage>(list) ? list : [],
}
}
export async function fetchDxOrders(): Promise<ApiResponse<DxOrder[]>> {
const res = await phpPost<RawHttpResponse>("/http/user/dx_order", {
cookie: document.cookie,
})
if (!res.success) return res
const list = res.data?.data ?? res.data
return { success: true, data: isArray<DxOrder>(list) ? list : [] }
}
// ==================== 长效套餐 ====================
export async function fetchCxPackages(): Promise<ApiResponse<CxPackage[]>> {
const res = await phpPost<RawHttpResponse>("/http/user/cx_package", {
cookie: document.cookie,
})
if (!res.success) return res
const list = res.data?.data ?? res.data
return {
success: true,
data: isArray<CxPackage>(list) ? list : [],
}
}
export async function fetchCxOrders(): Promise<ApiResponse<CxOrder[]>> {
const res = await phpPost<RawHttpResponse>("/http/user/cx_order", {
cookie: document.cookie,
})
if (!res.success) return res
const list = res.data?.data ?? res.data
return { success: true, data: isArray<CxOrder>(list) ? list : [] }
}
// ==================== 长效 IP 列表/提取/切换 ====================
export async function fetchLangIpList(
query: Record<string, unknown>,
): Promise<ApiResponse<LangIpListResult>> {
const res = await phpPost<RawHttpResponse>("/http/user/lang_ip_list", {
cookie: document.cookie,
data: query,
})
if (!res.success) return res
const d = unwrapD(res.data)
const items = isArray<LangIpItem>(d.items) ? d.items : []
return {
success: true,
data: { items, pager: parsePager(d.pager, items.length) },
}
}
export async function fetchSwitchList(
query: Record<string, unknown>,
): Promise<ApiResponse<SwitchListResult>> {
const res = await phpPost<RawHttpResponse>("/http/user/cx_switch", {
cookie: document.cookie,
data: query,
})
if (!res.success) return res
const d = unwrapD(res.data)
const items = isArray<SwitchRecord>(d.items) ? d.items : []
return {
success: true,
data: { items, pager: parsePager(d.pager, items.length) },
}
}
export async function fetchLangIps(
ids: string,
): Promise<ApiResponse<LangIpExtract[]>> {
const res = await phpPost<RawHttpResponse>("/http/user/get_lang_ip", {
cookie: document.cookie,
data: { ids },
})
if (!res.success) return res
const ips = unwrapD(res.data).ips
return {
success: true,
data: isArray<LangIpExtract>(ips) ? ips : [],
}
}
export async function fetchSwitchCities(
data: Record<string, unknown>,
switchPop: 0 | 1,
): Promise<ApiResponse<GameCity[]>> {
const endpoint =
switchPop === 1 ? "/http/product/in_city" : "/http/product/out_city"
const res = await phpPost<RawHttpResponse>(endpoint, data)
if (!res.success) return res
const cities = unwrapD(res.data).cities
return {
success: true,
data: isArray<GameCity>(cities) ? cities : [],
}
}

16
src/api/httpRecharge.ts Normal file
View 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",
)
}

View File

@@ -1,65 +1,9 @@
import type { ApiResponse } from "@/lib/api"
export const PHP_API_BASE_URL =
import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com"
const DEFAULT_TIMEOUT = 30_000
export type LineItem = {
city: string | number
daikuan: string | number
name: string | number
nasname: string | number
online: string | number
supply: string | number
}
export type LineData = {
count: number | string
use_count: number | string
data: LineItem[]
}
export type LineSearchResult = {
data: LineItem[]
}
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
try {
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) {
return {
success: false,
status: response.status,
message: "请求失败",
}
}
const data = (await response.json()) as T
return { success: true, data }
} catch (e) {
if (controller.signal.aborted) {
return {
success: false,
status: 408,
message: "请求超时,请稍后重试",
}
}
return {
success: false,
status: 500,
message: (e as Error).message || "网络错误",
}
} finally {
clearTimeout(timer)
}
}
import type { LineData, LineSearchResult } from "@/lib/models/line"
import { phpGet } from "./http"
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
return getJson<LineData>(
`${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`,
)
return phpGet<LineData>(`/script/linedata/display.php?product=${product}`)
}
export function searchLineData(
@@ -67,7 +11,5 @@ export function searchLineData(
info: string,
): Promise<ApiResponse<LineSearchResult>> {
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
return getJson<LineSearchResult>(
`${PHP_API_BASE_URL}/script/linedata/search.php?${query}`,
)
return phpGet<LineSearchResult>(`/script/linedata/search.php?${query}`)
}

View File

@@ -1,39 +1,14 @@
import type { ApiResponse } from "@/lib/api"
import type {
CreateOrderData,
CreateOrderRequest,
CreateTestAccountRequest,
OrderItem,
OrderQueryParams,
} from "@/lib/models/order"
import { callByUser } from "./base"
export type CreateOrderRequest = {
PackageId: number
OrderType: number
Account: string
Pwd: string
ConnectCount: number
MinPostfix?: number
MaxPostfix?: number
CouponId?: number
UseAccountAmount?: number
OPayType: number
PayChannel?: number
Price?: number
}
export type CreateTestAccountRequest = {
ProductId: number
PackageId: number
Account: string
Pwd: string
}
export type OrderInfo = {
OrderNo: string
OtherPayAmount: number
CreateTime: string
PayType: number
}
export type CreateOrderData = {
OrderInfo: OrderInfo
PayData: string
}
// ==================== 购买下单(php-api /product/*) ====================
export async function createOrder(
data: CreateOrderRequest,
@@ -62,3 +37,71 @@ export async function checkPayStatus(
"GET",
)
}
// ==================== 管理端订单(.NET /api/course/v1/order/*) ====================
// 订单分页列表
export async function fetchOrderPage(
params: OrderQueryParams,
): Promise<ApiResponse<OrderItem[]>> {
return await callByUser<OrderItem[]>(
"/api/course/v1/order/Page",
params,
"GET",
)
}
// 退款订单分页列表
export async function fetchRefundOrders(
params: OrderQueryParams,
): Promise<ApiResponse<OrderItem[]>> {
return await callByUser<OrderItem[]>(
"/api/course/v1/order/RefundOrders",
params,
"GET",
)
}
// 优惠券订单分页列表
export async function fetchCouponOrders(
params: OrderQueryParams,
): Promise<ApiResponse<OrderItem[]>> {
return await callByUser<OrderItem[]>(
"/api/course/v1/order/CouponOrders",
params,
"GET",
)
}
// 订单统计(Statistics 接口返回统计结构,待后端确认字段后收窄类型)
export async function fetchOrderStatistics(
params: OrderQueryParams,
): Promise<ApiResponse<Record<string, unknown>>> {
return await callByUser<Record<string, unknown>>(
"/api/course/v1/order/Statistics",
params,
"GET",
)
}
// 用户消费统计
export async function fetchUserConsumeStatistics(
params: Record<string, unknown>,
): Promise<ApiResponse<Record<string, unknown>>> {
return await callByUser<Record<string, unknown>>(
"/api/course/v1/order/UserConsumeStatistics",
params,
"GET",
)
}
// 订单导出
export async function exportOrders(
params: OrderQueryParams,
): Promise<ApiResponse<undefined>> {
return await callByUser<undefined>(
"/api/course/v1/order/Export",
params,
"GET",
)
}

43
src/api/record.ts Normal file
View 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
View 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
View 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,
)
}

View File

@@ -1,8 +1,7 @@
import type { ApiResponse } from "@/lib/api"
import type { SMSPurpose } from "@/lib/models/auth"
import { callPublic } from "./base"
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
export async function sendSMS(
phone: string,
purpose: SMSPurpose = "User_Code",

56
src/api/whitelist.ts Normal file
View 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,
})
}

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

View File

@@ -1,37 +1,18 @@
import type { ColumnDef } from "@tanstack/react-table"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Toaster, toast } from "sonner"
import {
fetchLineData,
type LineItem,
PHP_API_BASE_URL,
searchLineData,
} from "@/api/linedata"
import { PHP_API_BASE_URL } from "@/api/http"
import { fetchLineData, searchLineData } from "@/api/linedata"
import DataTable from "@/components/data-table"
import { Button } from "@/components/ui/button"
import Wrap from "@/components/wrap"
import { cn } from "@/lib/utils"
import type { LineItem } from "@/lib/models/line"
import { PRODUCTS } from "@/lib/models/line"
import { cn, downloadCsv } from "@/lib/utils"
const REFRESH_INTERVAL = 5 * 60 * 1000
const PAGE_SIZE_OPTIONS = [100, 200, 500]
type ProductOption = {
id: number
name: string
l2tp: string
sstp: string
}
const PRODUCTS: ProductOption[] = [
{ id: 29, name: "极狐IP", l2tp: "8899", sstp: "4430" },
{ id: 3, name: "极光IP", l2tp: "1234", sstp: "4430" },
// { id: 18, name: "蘑菇IP", l2tp: "8899", sstp: "4430" },
{ id: 27, name: "麒麟IP", l2tp: "123", sstp: "4432" },
{ id: 26, name: "猎豹IP", l2tp: "1234", sstp: "4430" },
{ id: 28, name: "水滴独享IP", l2tp: "123", sstp: "4432" },
{ id: 22, name: "火狐静态IP", l2tp: "888888", sstp: "5908" },
]
function toText(value: string | number): string {
return value === null || value === undefined ? "" : String(value)
}
@@ -120,7 +101,6 @@ export default function IpLinePage() {
setPageIndex(0)
const seq = ++requestSeq.current
const result = await searchLineData(productId ?? 0, info)
console.log("搜索的result", result)
if (seq !== requestSeq.current) return
if (result.success) {
@@ -154,29 +134,15 @@ export default function IpLinePage() {
"带宽",
"服务器状态",
]
const lines = [
header,
...rows.map(r => [
const data = rows.map(r => [
toText(r.name),
toText(r.city),
toText(r.supply),
toText(r.nasname),
toText(r.daikuan),
toText(r.online),
]),
]
const csv = lines
.map(line => line.map(v => `"${v.replaceAll('"', '""')}"`).join(","))
.join("\r\n")
const blob = new Blob([`\uFEFF${csv}`], {
type: "text/csv;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${currentName || "IP线路表"}.csv`
a.click()
URL.revokeObjectURL(url)
])
downloadCsv(`${currentName || "IP线路表"}.csv`, header, data)
toast.success("导出成功")
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { type CreateOrderRequest, createOrder } from "@/api/order"
import { createOrder } from "@/api/order"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -14,6 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import Wrap from "@/components/wrap"
import type { CreateOrderRequest } from "@/lib/models/order"
import type { Product } from "@/lib/models/product"
import { cn } from "@/lib/utils"
import alipay from "../_assets/alipay.svg"

View File

@@ -80,7 +80,7 @@ function OperatorSelect({
return (
<div className="flex flex-wrap gap-2">
{operators.map((op) => (
{operators.map(op => (
<button
key={op}
className={cn(
@@ -129,7 +129,7 @@ function RegionGrid({
H: "H",
I: "I",
}
regions.forEach((r) => {
regions.forEach(r => {
const key = letterMap[r.id] || "其他"
if (!groupedRegions[key]) groupedRegions[key] = []
groupedRegions[key].push(r)
@@ -143,7 +143,7 @@ function RegionGrid({
{letter}
</span>
<div className="flex flex-wrap gap-2 flex-1">
{items.map((region) => (
{items.map(region => (
<button
key={region.id}
className={cn(
@@ -155,7 +155,9 @@ function RegionGrid({
onClick={() => onSelect(region.name)}
>
{region.name}
<span className="ml-1 text-xs text-gray-400">余量:{region.stock}</span>
<span className="ml-1 text-xs text-gray-400">
余量:{region.stock}
</span>
</button>
))}
</div>
@@ -177,7 +179,7 @@ function BandwidthSelect({
return (
<div className="flex flex-wrap gap-2">
{options.map((bw) => (
{options.map(bw => (
<button
key={bw}
className={cn(
@@ -211,7 +213,7 @@ function DurationSelect({
return (
<div className="flex flex-wrap gap-2">
{options.map((opt) => (
{options.map(opt => (
<button
key={opt.value}
className={cn(
@@ -242,10 +244,10 @@ function ProjectSelect({
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
onChange={e => onChange(e.target.value)}
className="h-10 px-3 rounded-xl border border-gray-200 bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 w-full max-w-xs"
>
{projects.map((p) => (
{projects.map(p => (
<option key={p} value={p}>
{p}
</option>
@@ -281,7 +283,12 @@ export default function BuyStaticPage() {
// 根据类型计算价格
const getPrice = () => {
const bwMap: Record<string, number> = { "2M": 10, "4M": 20, "10M": 40, "20M": 80 }
const bwMap: Record<string, number> = {
"2M": 10,
"4M": 20,
"10M": 40,
"20M": 80,
}
const durationMap: Record<string, number> = { test: 0.1, day: 1, month: 10 }
const basePrice = bwMap[bandwidth] || 10
const durationPrice = durationMap[duration] || 0.1
@@ -315,7 +322,9 @@ export default function BuyStaticPage() {
</div>
) : (
<div className="mb-4">
<p className="text-sm text-gray-600">专线独享:个人独享整个线路,真实独享,永远不重复。</p>
<p className="text-sm text-gray-600">
专线独享:个人独享整个线路,真实独享,永远不重复。
</p>
<p className="mt-1 text-sm text-red-500">线路切换单日限制为20次</p>
<p className="mt-1 text-sm text-red-500">
如果没有自己想用的地区线路或线路余量不足,请联系客服上架
@@ -356,7 +365,7 @@ export default function BuyStaticPage() {
placeholder="请输入地区"
className="h-10 rounded-xl border-gray-200 flex-1"
value={regionSearch}
onChange={(e) => setRegionSearch(e.target.value)}
onChange={e => setRegionSearch(e.target.value)}
/>
<Button className="h-10 rounded-xl px-6 bg-blue-500 hover:bg-blue-600 text-white">
搜索
@@ -438,7 +447,9 @@ export default function BuyStaticPage() {
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
应付金额:
</Label>
<span className="text-xl font-bold text-orange-500">¥{getPrice()}</span>
<span className="text-xl font-bold text-orange-500">
¥{getPrice()}
</span>
</div>
{/* 余额 */}

View File

@@ -51,7 +51,6 @@ export default function ProductPage() {
const results = await Promise.allSettled(
TAB_SOURCES.map(source => source.fetch()),
)
console.log(results, "resultsresultsresults")
if (cancelled) return

View File

@@ -44,8 +44,8 @@ export default function PurchaseInfo() {
tabIndex={0}
className="w-full overflow-hidden rounded-lg border border-gray-200 shadow-sm cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setIsImageOpen(true)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
onKeyDown={e => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
setIsImageOpen(true)
}
@@ -75,7 +75,7 @@ export default function PurchaseInfo() {
<button
type="button"
className="absolute top-4 right-4 text-white bg-black/50 rounded-full w-10 h-10 text-2xl hover:bg-black/70 transition-colors pointer-events-auto"
onClick={(e) => {
onClick={e => {
e.stopPropagation()
setIsImageOpen(false)
}}

View File

@@ -59,7 +59,10 @@ export default function ShuidiPools({ onBuy }: ShuidiPoolsProps) {
<div className="text-sm text-gray-500 space-y-1.5 mt-5">
{pool.features.map((feature, index) => (
<div key={index} className="flex items-center justify-center gap-2 text-amber-500">
<div
key={index}
className="flex items-center justify-center gap-2 text-amber-500"
>
{feature}
</div>
))}

View File

@@ -19,6 +19,20 @@ type ApiResponse<T = undefined> =
data: T
// 响应头(key 统一小写),用于登录等场景从 header 取数据
headers?: Record<string, string>
// .NET 分页接口(ApiResultPaged)携带的总条数
totalCount?: number
}
export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
// 服务端分页结果
type PageResult<T> = {
total: number
list: T[]
}
export {
API_BASE_URL,
type ApiResponse,
CLIENT_ID,
CLIENT_SECRET,
type PageResult,
}

View 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
View File

@@ -0,0 +1,2 @@
// 短信验证码用途
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"

10
src/lib/models/cashOut.ts Normal file
View 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
View 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
}

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

View 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
View 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" },
]

View File

@@ -16,6 +16,58 @@ export type OrderItem = {
UpdateTime: string
}
// ==================== 下单接口(php-api /product/CreateOrder) ====================
export type CreateOrderRequest = {
PackageId: number
OrderType: number
Account: string
Pwd: string
ConnectCount: number
MinPostfix?: number
MaxPostfix?: number
CouponId?: number
UseAccountAmount?: number
OPayType: number
PayChannel?: number
Price?: number
}
export type CreateTestAccountRequest = {
ProductId: number
PackageId: number
Account: string
Pwd: string
}
export type OrderInfo = {
OrderNo: string
OtherPayAmount: number
CreateTime: string
PayType: number
}
export type CreateOrderData = {
OrderInfo: OrderInfo
PayData: string
}
// ==================== 管理端订单查询(.NET /api/course/v1/order/*) ====================
export type OrderQueryParams = {
PageIndex?: number
PageSize?: number
KeyWord?: string
OrderTypes?: number[]
BTime?: string
ETime?: string
ProductId?: number
PackageId?: number
Channel?: string
ProductIds?: number[]
PackageNames?: string[]
}
export type OrderFilters = {
orderType: string
productId: string
@@ -65,3 +117,61 @@ export function cutAccount(accounts: string, max = 15): string {
export function totalConnections(item: OrderItem): number {
return item.ConnectCount * item.AccountCount
}
// ==================== 表格展示辅助 ====================
export function formatMoney(value: number): string {
return value.toFixed(2)
}
export function formatDate(value: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}.${month}.${day}`
}
// 前端筛选(后端筛选接口就绪后可移除)
export function matchOrders(
list: OrderItem[],
filters: OrderFilters,
): OrderItem[] {
const keyword = filters.keyword.trim()
return list.filter(order => {
if (
filters.orderType !== "0" &&
order.OrderType !== Number(filters.orderType)
) {
return false
}
if (
filters.productId !== "0" &&
order.ProductId !== Number(filters.productId)
) {
return false
}
if (
filters.packageName !== "0" &&
order.PackageName !== filters.packageName
) {
return false
}
if (
keyword &&
!order.OrderNo.includes(keyword) &&
!order.Accounts.includes(keyword)
) {
return false
}
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
return false
}
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
return false
}
return true
})
}

279
src/lib/models/package.ts Normal file
View 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

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

View File

@@ -0,0 +1,6 @@
export type WhiteIpItem = {
id: number
ip: string
createTime: string
isLocked: string
}

View File

@@ -8,3 +8,49 @@ export type Children = { children: ReactNode }
export function cn(...inputs: ClassNameValue[]) {
return twMerge(inputs)
}
// 以文本文件形式下载内容
export function downloadTextFile(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = filename
anchor.style.display = "none"
document.body.appendChild(anchor)
anchor.click()
document.body.removeChild(anchor)
URL.revokeObjectURL(url)
}
// 导出 CSV(带 BOM,避免 Excel 打开中文乱码)
export function downloadCsv(
filename: string,
header: string[],
rows: (string | number)[][],
) {
const lines = [header, ...rows].map(line =>
line.map(v => `"${String(v).replaceAll('"', '""')}"`).join(","),
)
const csv = lines.join("\r\n")
const blob = new Blob([`\uFEFF${csv}`], {
type: "text/csv;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename.endsWith(".csv") ? filename : `${filename}.csv`
a.click()
URL.revokeObjectURL(url)
}
// 服务端未返回总条数时,按当前页数据量估算
export function estimateTotal(
pagerTotal: number | undefined,
page: number,
pageSize: number,
itemCount: number,
): number {
if (pagerTotal && pagerTotal > 0) return pagerTotal
return itemCount > 0 ? (page - 1) * pageSize + itemCount : 0
}

View File

@@ -1,4 +1,5 @@
import { createBrowserRouter } from "react-router-dom"
import CashOutPage from "@/admin/cashOut/page"
import Channels from "@/admin/channels/page"
import CouponPage from "@/admin/coupon/page"
import Dashboard from "@/admin/dashboard/page"
@@ -12,6 +13,7 @@ import LongPage from "@/admin/resources/long"
import ShortPage from "@/admin/resources/short"
import RosorderPage from "@/admin/rosorder/page"
import Whitelist from "@/admin/whitelist/page"
import WhitelistSUPage from "@/admin/whitelistSU/page"
import AuthGuard from "@/components/authGuard"
import HomePage from "@/home"
import ClientPage from "@/home/client"
@@ -57,6 +59,7 @@ export const router = createBrowserRouter([
{ index: true, element: <Dashboard /> },
{ path: "channels", element: <Channels /> },
{ path: "whitelist", element: <Whitelist /> },
{ path: "whitelistSU", element: <WhitelistSUPage /> },
{ path: "funds", element: <Funds /> },
{
path: "product",
@@ -65,6 +68,7 @@ export const router = createBrowserRouter([
{ path: "short", element: <ShortPage /> },
],
},
{ path: "cashOut", element: <CashOutPage /> },
{ path: "orders", element: <OrdersPage /> },
{ path: "refundOrders", element: <RefundOrdersPage /> },
{ path: "httpRecharge", element: <HttpRechargePage /> },