diff --git a/src/admin/cashOut/page.tsx b/src/admin/cashOut/page.tsx new file mode 100644 index 0000000..c00626d --- /dev/null +++ b/src/admin/cashOut/page.tsx @@ -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 ( + +
+

提现管理

+ + 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: "提现时间", + }, + ]} + /> +
+
+ ) +} diff --git a/src/admin/channels/mock.ts b/src/admin/channels/mock.ts new file mode 100644 index 0000000..40748a5 --- /dev/null +++ b/src/admin/channels/mock.ts @@ -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: "", + }, +] diff --git a/src/admin/channels/page.tsx b/src/admin/channels/page.tsx index a2e9122..e13f172 100644 --- a/src/admin/channels/page.tsx +++ b/src/admin/channels/page.tsx @@ -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 -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({}) - const [status, setStatus] = useState<"load" | "done">("done") - // 行选择状态 const [rowSelection, setRowSelection] = useState>({}) const { control, handleSubmit, reset } = useForm({ @@ -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) + setFilterValues(values) + setRowSelection({}) } 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() { data={filteredData} - status={status} + status="done" columns={[ { accessorKey: "startAt", header: "开通时间" }, { accessorKey: "product", header: "产品" }, diff --git a/src/admin/clients.tsx b/src/admin/clients.tsx index 944474f..20965dc 100644 --- a/src/admin/clients.tsx +++ b/src/admin/clients.tsx @@ -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() { } - label="账户总览" - expand={navbar} - /> - } - label="白名单" + icon={} + label="个人中心" expand={navbar} /> + + } @@ -172,6 +168,12 @@ export function Navbar() { label="资金明细" expand={navbar} /> + } + label="软路由订单" + expand={navbar} + /> } @@ -181,21 +183,22 @@ export function Navbar() { } - label="退货管理" + label="退货订单管理" expand={navbar} /> } - label="优惠券" + label="我的优惠券" expand={navbar} /> } label="提现管理" expand={navbar} /> + } @@ -214,18 +217,24 @@ export function Navbar() { label="长效管理" expand={navbar} /> - } - label="软路由" - expand={navbar} - /> } label="使用记录" expand={navbar} /> + } + label="白名单" + expand={navbar} + /> + } + label="白名单(短效)" + expand={navbar} + /> @@ -287,3 +296,31 @@ function NavItem(props: { ) } + +function NavTitle(props: { label: string }) { + return ( +

+ + {props.label} + + +

+ ) +} diff --git a/src/admin/coupon/page.tsx b/src/admin/coupon/page.tsx index 919a30e..8d66fef 100644 --- a/src/admin/coupon/page.tsx +++ b/src/admin/coupon/page.tsx @@ -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 优惠券 + 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 ( + + + {/* 优惠券左侧 - 面额 */} +
+
+ {isDiscount ? ( + <> + + {item.coupon.couponValue} + + 折 + + ) : ( + <> + ¥ + + {item.coupon.couponValue} + + + )} +
+ + {/* 优惠券右侧 - 详情 */} +
+
+

{item.coupon.name}

+ {isUsed ? ( + + + 已使用 + + ) : ( + + + 可使用 + + )} +
+ +
+ + + {item.coupon.allowMinAmount > 0 + ? `满${item.coupon.allowMinAmount}元可用` + : "无限制"} + + + + {new Date(item.orgin.startTime).toLocaleDateString()} -{" "} + {new Date(item.orgin.endTime).toLocaleDateString()} + + + + {item.orgin.remark} + +
+
+
+ + {/* 已使用水印效果 */} + {isUsed && ( +
+ +
+ )} +
+
+ ) + } + + return ( + +
+
+
+

我的优惠券

+ + 共 {mockCoupons.length} 张 + +
+ + + + + 可使用 ({availableCoupons.length}) + + + 已使用 ({usedCoupons.length}) + + + + + {availableCoupons.length === 0 ? ( +
+ +

暂无可用优惠券

+

快去参与活动领取吧

+
+ ) : ( +
+ {availableCoupons.map(renderCoupon)} +
+ )} +
+ + + {usedCoupons.length === 0 ? ( +
+ +

暂无已使用的优惠券

+
+ ) : ( +
+ {usedCoupons.map(renderCoupon)} +
+ )} +
+
+
+
+
+ ) } diff --git a/src/admin/dashboard/mock.ts b/src/admin/dashboard/mock.ts new file mode 100644 index 0000000..f0253da --- /dev/null +++ b/src/admin/dashboard/mock.ts @@ -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, +} diff --git a/src/admin/dashboard/page.tsx b/src/admin/dashboard/page.tsx index 32ded19..a3a8ac6 100644 --- a/src/admin/dashboard/page.tsx +++ b/src/admin/dashboard/page.tsx @@ -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 ( - {/* ====== 第二行:最近订单(占左侧3列) ====== */} + {/* 可以优化掉不要 */}

最近订单

@@ -259,8 +219,7 @@ export default function DashboardPage() {
- {/* ====== 第三、四行:消费信息 + IP账号 ====== */} - {/* 消费信息 - 占左侧3列,跨2行 */} + {/* ====== 消费信息 + IP账号 ====== */}

消费信息

@@ -373,7 +332,7 @@ export default function DashboardPage() {
- {/* ====== IP账号(占右侧1列,跨2行) ====== */} + {/* ====== IP账号 ====== */}

IP账号

diff --git a/src/admin/funds/page.tsx b/src/admin/funds/page.tsx index 3ea4955..3389436 100644 --- a/src/admin/funds/page.tsx +++ b/src/admin/funds/page.tsx @@ -1,6 +1,8 @@ import DataTable from "@/components/data-table" import Page from "@/components/page" + + export default function FundsPage() { return ( diff --git a/src/admin/httpRecharge/page.tsx b/src/admin/httpRecharge/page.tsx index 1ff40ae..cb2e876 100644 --- a/src/admin/httpRecharge/page.tsx +++ b/src/admin/httpRecharge/page.tsx @@ -1,32 +1,36 @@ import DataTable from "@/components/data-table" import Page from "@/components/page" +import type { HttpRechargeItem } from "@/lib/models/httpRecharge" + export default function HttpRechargePage() { return ( -

HTTP 充值记录

- +
+

HTTP 充值记录

+ + data={[]} + status="done" + columns={[ + { + accessorKey: "create_time", + header: "交易时间", + }, + { + accessorKey: "ju_money", + header: "充值H币", + }, + { + accessorKey: "pay_type", + header: "充值渠道", + }, + { + accessorKey: "pay_money", + header: "充值金额", + }, + ]} + /> +
) } diff --git a/src/admin/orders/mock.ts b/src/admin/mock/orders.ts similarity index 100% rename from src/admin/orders/mock.ts rename to src/admin/mock/orders.ts diff --git a/src/admin/orders/page.tsx b/src/admin/orders/page.tsx index f523e9e..d04524d 100644 --- a/src/admin/orders/page.tsx +++ b/src/admin/orders/page.tsx @@ -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 {accounts} - - return ( - - - {cutAccount(accounts)} - - {accounts} - - ) -} +// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchOrderPage 返回的分页数据 const columns: ColumnDef[] = [ { @@ -111,69 +81,18 @@ const columns: ColumnDef[] = [ }, ] -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(DEFAULT_FILTERS) - const [appliedFilters, setAppliedFilters] = - useState(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) => - setFilters({ ...filters, ...partial }) - - const handleSearch = () => { - setAppliedFilters(filters) - setPage(1) - } + const { + filters, + patch, + handleSearch, + page, + setPage, + pageSize, + handleSizeChange, + filtered, + paged, + } = useOrderFilters(MOCK_ORDERS) return ( @@ -278,10 +197,7 @@ export default function OrdersPage() { size: pageSize, total: filtered.length, onPageChange: setPage, - onSizeChange: size => { - setPageSize(size) - setPage(1) - }, + onSizeChange: handleSizeChange, }} />
diff --git a/src/admin/record/page.tsx b/src/admin/record/page.tsx index c7316f4..bafd1bc 100644 --- a/src/admin/record/page.tsx +++ b/src/admin/record/page.tsx @@ -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[] = [ { accessorKey: "packType", diff --git a/src/admin/refundOrders/mock.ts b/src/admin/refundOrders/mock.ts deleted file mode 100644 index 7068ded..0000000 --- a/src/admin/refundOrders/mock.ts +++ /dev/null @@ -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" }, -] diff --git a/src/admin/refundOrders/page.tsx b/src/admin/refundOrders/page.tsx index 9960606..449a48c 100644 --- a/src/admin/refundOrders/page.tsx +++ b/src/admin/refundOrders/page.tsx @@ -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 {accounts} - - return ( - - - {cutAccount(accounts)} - - {accounts} - - ) -} +// 后端订单接口就绪后,将 MOCK_ORDERS 替换为 fetchRefundOrders 返回的分页数据 const columns: ColumnDef[] = [ { @@ -65,11 +34,6 @@ const columns: ColumnDef[] = [ accessorKey: "OrderNo", header: "订单编号", }, - { - accessorKey: "OrderType", - header: "类型", - cell: ({ row }) => formatOrderType(row.original.OrderType), - }, { accessorKey: "ProductName", header: "产品", @@ -110,69 +74,18 @@ const columns: ColumnDef[] = [ }, ] -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(DEFAULT_FILTERS) - const [appliedFilters, setAppliedFilters] = - useState(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) => - setFilters({ ...filters, ...partial }) - - const handleSearch = () => { - setAppliedFilters(filters) - setPage(1) - } + const { + filters, + patch, + handleSearch, + page, + setPage, + pageSize, + handleSizeChange, + filtered, + paged, + } = useOrderFilters(MOCK_ORDERS) return ( @@ -250,7 +163,7 @@ export default function RefundOrdersPage() {
-

订单管理

+

退款订单管理

{ - setPageSize(size) - setPage(1) - }, + onSizeChange: handleSizeChange, }} />
diff --git a/src/admin/resources/long-renew-dialog.tsx b/src/admin/resources/long-renew-dialog.tsx new file mode 100644 index 0000000..b76413f --- /dev/null +++ b/src/admin/resources/long-renew-dialog.tsx @@ -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 ( + !open && onClose()}> + + {source && ( + <> + + {title} + + +
+
+
+ 套餐周期: +
+
+ {PERIOD_OPTIONS.map(opt => ( + + ))} +
+
+ +
+
+ 购买时长: +
+
+ + + {periodAmount} + {periodUnit} + + +
+
+
+ + + + + + )} +
+
+ ) +} diff --git a/src/admin/resources/long-switch-dialog.tsx b/src/admin/resources/long-switch-dialog.tsx new file mode 100644 index 0000000..52fd308 --- /dev/null +++ b/src/admin/resources/long-switch-dialog.tsx @@ -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([]) + 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 ( + !open && onClose()}> + + {ip && ( + <> + + {title} + + +
+ {ip.isAbroad === 0 && ( +
+
+ 运营商选择: +
+
+ {ISP_OPTIONS.map(opt => ( + + ))} +
+
+ )} + +
+
+ 地区选择: +
+
+ + {cities.map(city => ( + + ))} +
+
+
+ + + + + + )} +
+
+ ) +} diff --git a/src/admin/resources/long.tsx b/src/admin/resources/long.tsx index d555a13..f648ca3 100644 --- a/src/admin/resources/long.tsx +++ b/src/admin/resources/long.tsx @@ -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 长效管理 + const navigate = useNavigate() + const [activeTab, setActiveTab] = useState("ips") + + // 已开通长效 + const [ipList, setIpList] = useState([]) + const [ipStatus, setIpStatus] = useState<"load" | "done" | "fail">("load") + const [ipPage, setIpPage] = useState(1) + const [ipTotal, setIpTotal] = useState(0) + const [rowSelection, setRowSelection] = useState>({}) + + // 全部套餐 + const [packages, setPackages] = useState([]) + const [packageStatus, setPackageStatus] = useState<"load" | "done" | "fail">( + "load", + ) + + // 购买记录 + const [orders, setOrders] = useState([]) + const [orderStatus, setOrderStatus] = useState<"load" | "done" | "fail">( + "load", + ) + + // IP切换记录 + const [switchList, setSwitchList] = useState([]) + const [switchStatus, setSwitchStatus] = useState<"load" | "done" | "fail">( + "load", + ) + const [switchPage, setSwitchPage] = useState(1) + const [switchTotal, setSwitchTotal] = useState(0) + + // 弹窗 + const [renewSource, setRenewSource] = useState(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[]>( + () => [ + { + 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 ( +
+ + {ip.isAbroad === 1 && ip.isRelayed === 1 && ( + + )} + +
+ ) + }, + }, + ], + [], + ) + + const packageColumns = useMemo[]>( + () => [ + { + accessorKey: "isAbroad", + header: "套餐类型", + cell: ({ row }) => formatAbroad(row.original.isAbroad), + }, + { accessorKey: "shareType", header: "模式" }, + { accessorKey: "ipAmount", header: "可用IP数量(个)" }, + { accessorKey: "expireTime", header: "过期时间" }, + { + id: "actions", + header: "续费", + cell: ({ row }) => ( + + ), + }, + ], + [], + ) + + const orderColumns = useMemo[]>( + () => [ + { 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: () => ( + + ), + }, + ], + [navigate], + ) + + const switchColumns = useMemo[]>( + () => [ + { accessorKey: "oldIp", header: "原IP" }, + { accessorKey: "oldArea", header: "原属地" }, + { accessorKey: "newIp", header: "切换后IP" }, + { accessorKey: "newArea", header: "切换后属地" }, + { accessorKey: "reason", header: "切换方式" }, + { accessorKey: "createTime", header: "切换时间" }, + ], + [], + ) + + return ( + +
+

长效套餐

+ + + 已开通长效 + 全部套餐 + 购买记录 + IP切换记录 + + + +
+ + + 格式:IP|http端口|socks5端口|账号|密码 + +
+ String(row.id)} + pagination={{ + page: ipPage - 1, + size: PAGE_SIZE, + total: ipTotal, + sizeOptions: [PAGE_SIZE], + onPageChange: handlePageChange, + }} + /> +
+ + + + + + + + + + + + +
+
+ + setRenewSource(null)} + onSuccess={() => { + void loadIps(ipPage) + void loadPackages() + }} + /> + + setSwitchTarget(null)} + onSuccess={() => void loadIps(ipPage)} + /> +
+ ) } diff --git a/src/admin/resources/short-api-link.tsx b/src/admin/resources/short-api-link.tsx new file mode 100644 index 0000000..628e227 --- /dev/null +++ b/src/admin/resources/short-api-link.tsx @@ -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 ( +
+
+ {label} +
+
+ {children} +
+
+ ) +} + +function RadioItem({ + idPrefix, + value, + label, +}: { + idPrefix: string + value: string + label: string +}) { + const id = `${idPrefix}-${value}` + return ( +
+ + +
+ ) +} + +export default function ShortApiLink({ packages }: { packages: DxPackage[] }) { + const [packId, setPackId] = useState("") + const [config, setConfig] = useState({ + num: 1, + gt: 0, + port: 1, + type: 1, + lb: 1, + distinct: 2, + isp: 0, + aw: 0, + at: 1, + sp: 1, + }) + const [regions, setRegions] = useState([]) + const [token, setToken] = useState(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) => + 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 ( +
+ + + + + +
+ + patchConfig({ + num: Math.min(200, Math.max(1, Number(e.target.value) || 1)), + }) + } + className="w-24" + /> + + 个IP{" "} + 单次可提取最大数量 200 个 + +
+
+ + {(isUnlimited || config.pt === 21) && ( + + patchConfig({ gt: Number(v) })} + className="flex flex-wrap" + > + {DURATION_OPTIONS.map(opt => ( + + ))} + + + )} + + + patchConfig({ port: Number(v) })} + className="flex flex-wrap" + > + {PROTOCOL_OPTIONS.map(opt => ( + + ))} + + + + + patchConfig({ type: Number(v) })} + className="flex flex-wrap" + > + {FORMAT_OPTIONS.map(opt => ( + + ))} + + + + + patchConfig({ lb: Number(v) })} + className="flex flex-wrap items-center" + > + {LINE_BREAK_OPTIONS.map(opt => ( + + ))} + patchConfig({ clb: e.target.value })} + placeholder="请输入换行符" + className="w-36 h-8" + /> + + + + +
+
+ + +
+
+ {REGION_OPTIONS.map(region => ( + + ))} +
+
+
+ + + {isUnlimited ? ( + 不去重 + ) : ( + patchConfig({ distinct: Number(v) })} + className="flex flex-wrap" + > + + + + )} + + + + patchConfig({ isp: Number(v) })} + className="flex flex-wrap" + > + {ISP_OPTIONS.map(opt => ( + + ))} + + + + + patchConfig({ aw: Number(v) })} + className="flex flex-wrap" + > + + + + + + {!isUnlimited && ( + + patchConfig({ at: Number(v) })} + className="flex flex-wrap" + > + {AUTH_MODE_OPTIONS.map(opt => ( + + ))} + + + )} + + {config.at !== 1 && !isUnlimited && ( + + patchConfig({ sp: Number(v) })} + className="flex flex-wrap items-center" + > + {SEPARATOR_OPTIONS.map(opt => ( + + ))} + patchConfig({ csp: e.target.value })} + placeholder="请输入分割符" + className="w-36 h-8" + /> + + + )} + +
+ +
+ +
+
+ {link || "点击“生成API链接”后此处显示链接"} +
+ + +
+ + {currentPackage && ( +

+ 已选套餐:({currentPackage.id}){currentPackage.name} + ,套餐类型 {currentPackage.planType},有效时长{" "} + {currentPackage.durationType} +

+ )} +
+ ) +} diff --git a/src/admin/resources/short-renew-dialog.tsx b/src/admin/resources/short-renew-dialog.tsx new file mode 100644 index 0000000..cc210e2 --- /dev/null +++ b/src/admin/resources/short-renew-dialog.tsx @@ -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 ( + !open && onClose()}> + + {pkg && ( + <> + + {pkg.name} - 续费 + + +
+
+
+ 套餐周期: +
+
+ {PERIOD_OPTIONS.filter( + opt => + !(pkg.planType === SHORT_PLAN_DAILY && opt.value === 90), + ).map(opt => ( + + ))} +
+
+ +
+
+ 购买时长: +
+
+ + + {periodAmount} + {periodUnit} + + +
+
+ + {pkg.planType === SHORT_PLAN_UNLIMITED && ( +

+ 无限量白名单上限:{whitelist} 个 +

+ )} +
+ + + + + + )} +
+
+ ) +} diff --git a/src/admin/resources/short.tsx b/src/admin/resources/short.tsx index e943627..70cd2ac 100644 --- a/src/admin/resources/short.tsx +++ b/src/admin/resources/short.tsx @@ -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 短效套餐 + const navigate = useNavigate() + const [activeTab, setActiveTab] = useState("packages") + + const [packages, setPackages] = useState([]) + const [orders, setOrders] = useState([]) + 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(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[]>( + () => [ + { 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 不支持续费 + } + return ( + + ) + }, + }, + ], + [], + ) + + const orderColumns = useMemo[]>( + () => [ + { 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: () => ( + + ), + }, + ], + [navigate], + ) + + return ( + +
+

短效套餐

+ + + 全部套餐 + 购买记录 + 提取短效 + + + + + + + + + + + + + + +
+ + setRenewPackage(null)} + onSuccess={() => void loadPackages()} + /> +
+ ) } diff --git a/src/admin/rosorder/page.tsx b/src/admin/rosorder/page.tsx index ad46e00..2974460 100644 --- a/src/admin/rosorder/page.tsx +++ b/src/admin/rosorder/page.tsx @@ -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 软路由页面 + return ( + +
+

软路由订单列表

+ + 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: "购买时间", + }, + ]} + /> +
+
+ ) } diff --git a/src/admin/whitelist/page.tsx b/src/admin/whitelist/page.tsx index db4be2c..39a6e2e 100644 --- a/src/admin/whitelist/page.tsx +++ b/src/admin/whitelist/page.tsx @@ -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 白名单 + const [ip, setIp] = useState("") + const [list, setList] = useState([]) + + 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 ( + +
+
+
+ setIp(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleSave()} + placeholder="请输入IP地址并点击保存白名单" + className="max-w-sm" + /> + +
+ +
+

当前白名单列表

+ + 当前仅允许保留白名单数量:5个 + +
+ + + data={list} + status="done" + columns={[ + { + accessorKey: "ip", + header: "IP地址", + }, + { + accessorKey: "createTime", + header: "设置时间", + }, + { + accessorKey: "isLocked", + header: "锁定状态", + cell: ({ row }) => { + const isLocked = row.original.isLocked === "已锁定" + return ( + + {row.original.isLocked} + + ) + }, + }, + { + id: "actions", + header: "操作", + cell: ({ row }) => { + const item = row.original + // TODO: 接入 lockWhiteIp / deleteWhiteIp + return ( +
+ + +
+ ) + }, + }, + ]} + /> +
+
+
+ ) } diff --git a/src/admin/whitelistSU/page.tsx b/src/admin/whitelistSU/page.tsx new file mode 100644 index 0000000..241af12 --- /dev/null +++ b/src/admin/whitelistSU/page.tsx @@ -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([]) + 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 ( + +
+
+
+ setIp(e.target.value)} + placeholder="请输入IP地址并点击保存白名单" + className="max-w-sm" + onKeyDown={e => e.key === "Enter" && handleSaveIp()} + /> + +
+ +
+

当前白名单列表

+ + 当前白名单数量:{whiteLimit} 个 + + +
+ + + data={list} + status="done" + columns={[ + { + accessorKey: "ip", + header: "IP地址", + }, + { + accessorKey: "createTime", + header: "设置时间", + }, + { + accessorKey: "isLocked", + header: "锁定状态", + cell: ({ row }) => { + const isLocked = row.original.isLocked === "已锁定" + return ( + + {row.original.isLocked} + + ) + }, + }, + { + id: "actions", + header: "操作", + cell: ({ row }) => { + const item = row.original + return ( +
+ + +
+ ) + }, + }, + ]} + /> +
+
+ + {/* 调整白名单上限弹窗 */} + + + + 调整白名单上限 + +
+
+
+ + + {tempWhiteLimit} + + +
+
+ {tempDiscount === 10 ? ( + + 价格无折扣 + + ) : ( + + 当前已应用 {tempDiscount * 10}% 折扣 + + )} +
+
+
+ 调整后到期时间:{newExpire.toLocaleString()} +
+
+ + + + +
+
+
+ ) +} diff --git a/src/api/base.ts b/src/api/base.ts index c8d9999..29e54ab 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -70,6 +70,7 @@ async function request( Message?: string Data?: unknown data?: unknown + TotalCount?: number } if (!response.ok) { return { @@ -85,6 +86,8 @@ async function request( 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( data?: unknown, method: "GET" | "POST" = "POST", ): Promise> { - 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, + )) { + 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> => { const maxAttempts = mode === "device" ? 2 : 1 @@ -202,7 +226,7 @@ async function call( auth = `Bearer ${token}` } - const resp = await request(endpoint, { method, body, auth }) + const resp = await request(url, { method, body, auth }) if (!resp.success && resp.status === 401) { if (mode === "user") { diff --git a/src/api/cashOut.ts b/src/api/cashOut.ts new file mode 100644 index 0000000..c0c8680 --- /dev/null +++ b/src/api/cashOut.ts @@ -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, +): Promise> { + return await callByUser( + "/api/course/v1/order/CashOutList", + params, + "GET", + ) +} + +export async function handleCashOut( + data: Record, +): Promise> { + return await callByUser( + "/api/course/v1/order/CashOutProcess", + data, + ) +} diff --git a/src/api/channel.ts b/src/api/channel.ts new file mode 100644 index 0000000..d24176e --- /dev/null +++ b/src/api/channel.ts @@ -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, +): Promise> { + return await callByUser( + "/api/course/v1/productaccount/Page", + params, + "GET", + ) +} diff --git a/src/api/coupon.ts b/src/api/coupon.ts new file mode 100644 index 0000000..824d760 --- /dev/null +++ b/src/api/coupon.ts @@ -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> { + return await callByUser( + "/api/sells/v1/coupon/Page", + params, + "GET", + ) +} + +export async function disabledCoupon( + id: number, +): Promise> { + return await callByUser("/api/sells/v1/coupon/Disabled", { + Id: id, + }) +} + +export async function deleteCoupon( + id: number, +): Promise> { + return await callByUser("/api/sells/v1/coupon/Delete", { id }) +} + +export async function giveCoupon( + data: Record, +): Promise> { + return await callByUser("/api/sells/v1/coupon/Give", data) +} diff --git a/src/api/dashboard.ts b/src/api/dashboard.ts new file mode 100644 index 0000000..0a836c8 --- /dev/null +++ b/src/api/dashboard.ts @@ -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> { + return fetchHttpBalance() +} + +export async function fetchDashboardConsumption( + params: Record, +): Promise>> { + return await callByUser>( + "/api/course/v1/order/UserConsumeStatistics", + params, + "GET", + ) +} + +export async function fetchDashboardRecentOrders( + params: OrderQueryParams, +): Promise> { + return await callByUser( + "/api/course/v1/order/Page", + { ...params, PageIndex: 1, PageSize: 10 }, + "GET", + ) +} + +// TODO: 账号信息/IP 统计接口路径待后端确认 +export async function fetchDashboardAccountInfo(): Promise< + ApiResponse> +> { + return await callByUser>( + "/api/baseinfo/v1/user/Info", + undefined, + "GET", + ) +} + +export async function fetchDashboardIpStats(): Promise< + ApiResponse> +> { + return await callByUser>( + "/api/course/v1/order/IpAccountStatistics", + undefined, + "GET", + ) +} diff --git a/src/api/http.ts b/src/api/http.ts index 52c1542..0dde00e 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -6,6 +6,22 @@ import type { GameOrderData, HttpOrderInfo, } from "@/lib/models/http" +import type { + ChangeIpData, + CxOrder, + CxPackage, + DxOrder, + DxPackage, + LangIpExtract, + LangIpItem, + LangIpListResult, + OrderActionResult, + PackageOrderInfo, + Pager, + SwitchListResult, + SwitchRecord, + UserApiToken, +} from "@/lib/models/package" export const PHP_API_BASE_URL = import.meta.env.VITE_PHP_API_BASE_URL ?? "https://php-api.juip.com" @@ -14,18 +30,27 @@ const DEFAULT_TIMEOUT = 30_000 type RawHttpResponse = Record -async function postJson( +// ==================== 统一 PHP 请求底层 ==================== +// 所有 /http/* 接口都带 cookie 走同一出口:超时、错误归一化在此层处理, +// 各接口函数只关心业务数据,不再各自实现 fetch。 +// credentials 仅对需要登录态的 POST 开启;公开脚本 GET 接口(如线路表) +// 不带 cookie,避免跨域时服务端 Access-Control-Allow-Origin 配置不匹配。 + +export async function phpFetch( + method: "GET" | "POST", path: string, - body: unknown, + body?: unknown, + options: { credentials?: RequestCredentials } = {}, ): Promise> { + 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( } } -function unwrapD(raw: RawHttpResponse | undefined): Record { +export async function phpPost( + path: string, + body?: unknown, +): Promise> { + return phpFetch("POST", path, body, { credentials: "include" }) +} + +export async function phpGet( + path: string, +): Promise> { + return phpFetch("GET", path) +} + +// ==================== 响应解析工具 ==================== + +// 老 PHP 接口习惯把业务数据包在 d 字段里 +export function unwrapD( + raw: RawHttpResponse | undefined, +): Record { const d = raw?.d if (d && typeof d === "object" && !Array.isArray(d)) { return d as Record @@ -63,43 +106,71 @@ function unwrapD(raw: RawHttpResponse | undefined): Record { return {} } +export function isArray(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 + 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> { - const res = await postJson("/http/product/city", game) + const res = await phpPost("/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(cities) ? cities : [], } } export async function fetchGameList( game: GameOrderData, ): Promise> { - const res = await postJson("/http/product/game", game) + const res = await phpPost("/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(games) ? games : [], } } export async function fetchGameLineCount( game: GameOrderData, ): Promise> { - const res = await postJson("/http/product/linecount", game) + const res = await phpPost("/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> { - const res = await postJson( + return calcOrderPrice(orderInfo as unknown as PackageOrderInfo) +} + +export async function calcOrderPrice( + orderInfo: PackageOrderInfo, +): Promise> { + const res = await phpPost( "/http/product/calc_price", orderInfo, ) @@ -113,10 +184,12 @@ export async function calcGamePrice( } } +// ==================== 订单 ==================== + export async function createHttpOrder( orderInfo: HttpOrderInfo, ): Promise> { - const res = await postJson("/http/order/create_order", { + const res = await phpPost("/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, }, } } +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> { + const res = await phpPost("/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 & { data: ChangeIpData }, +): Promise> { + const res = await phpPost("/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> { - const res = await postJson( + const res = await phpPost( "/http/user/get_balance", - { - cookie: document.cookie, - }, + { 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> { + const res = await phpPost("/http/user/get_user_token", { + cookie: document.cookie, + }) + if (!res.success) return res + const raw = (res.data?.data ?? res.data ?? {}) as Record + return { + success: true, + data: { + uid: raw.uid as UserApiToken["uid"], + token: typeof raw.token === "string" ? raw.token : "", + }, + } +} + +export async function fetchWhiteLimitMax(): Promise> { + const res = await phpPost("/http/user/white_limit_get_su", { + cookie: document.cookie, + }) + if (!res.success) return res + const raw = (res.data?.data ?? res.data ?? {}) as Record + // 返回值可能是 { su: n } / { data: n },也可能直接是数字,逐层尝试 + return { success: true, data: toNumber(raw.su ?? raw.data ?? raw) } +} + +// ==================== 短效套餐 ==================== + +export async function fetchDxPackages(): Promise> { + const res = await phpPost("/http/user/dx_package", { + cookie: document.cookie, + }) + if (!res.success) return res + const list = res.data?.data ?? res.data + return { + success: true, + data: isArray(list) ? list : [], + } +} + +export async function fetchDxOrders(): Promise> { + const res = await phpPost("/http/user/dx_order", { + cookie: document.cookie, + }) + if (!res.success) return res + const list = res.data?.data ?? res.data + return { success: true, data: isArray(list) ? list : [] } +} + +// ==================== 长效套餐 ==================== + +export async function fetchCxPackages(): Promise> { + const res = await phpPost("/http/user/cx_package", { + cookie: document.cookie, + }) + if (!res.success) return res + const list = res.data?.data ?? res.data + return { + success: true, + data: isArray(list) ? list : [], + } +} + +export async function fetchCxOrders(): Promise> { + const res = await phpPost("/http/user/cx_order", { + cookie: document.cookie, + }) + if (!res.success) return res + const list = res.data?.data ?? res.data + return { success: true, data: isArray(list) ? list : [] } +} + +// ==================== 长效 IP 列表/提取/切换 ==================== + +export async function fetchLangIpList( + query: Record, +): Promise> { + const res = await phpPost("/http/user/lang_ip_list", { + cookie: document.cookie, + data: query, + }) + if (!res.success) return res + const d = unwrapD(res.data) + const items = isArray(d.items) ? d.items : [] + return { + success: true, + data: { items, pager: parsePager(d.pager, items.length) }, + } +} + +export async function fetchSwitchList( + query: Record, +): Promise> { + const res = await phpPost("/http/user/cx_switch", { + cookie: document.cookie, + data: query, + }) + if (!res.success) return res + const d = unwrapD(res.data) + const items = isArray(d.items) ? d.items : [] + return { + success: true, + data: { items, pager: parsePager(d.pager, items.length) }, + } +} + +export async function fetchLangIps( + ids: string, +): Promise> { + const res = await phpPost("/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(ips) ? ips : [], + } +} + +export async function fetchSwitchCities( + data: Record, + switchPop: 0 | 1, +): Promise> { + const endpoint = + switchPop === 1 ? "/http/product/in_city" : "/http/product/out_city" + const res = await phpPost(endpoint, data) + if (!res.success) return res + const cities = unwrapD(res.data).cities + return { + success: true, + data: isArray(cities) ? cities : [], + } } diff --git a/src/api/httpRecharge.ts b/src/api/httpRecharge.ts new file mode 100644 index 0000000..60f0fc3 --- /dev/null +++ b/src/api/httpRecharge.ts @@ -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, +): Promise> { + return await callByUser( + "/api/course/v1/order/HttpRecharges", + params, + "GET", + ) +} diff --git a/src/api/linedata.ts b/src/api/linedata.ts index 95574e9..2c1c79a 100644 --- a/src/api/linedata.ts +++ b/src/api/linedata.ts @@ -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(url: string): Promise> { - 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> { - return getJson( - `${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`, - ) + return phpGet(`/script/linedata/display.php?product=${product}`) } export function searchLineData( @@ -67,7 +11,5 @@ export function searchLineData( info: string, ): Promise> { const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}` - return getJson( - `${PHP_API_BASE_URL}/script/linedata/search.php?${query}`, - ) + return phpGet(`/script/linedata/search.php?${query}`) } diff --git a/src/api/order.ts b/src/api/order.ts index 9292a9a..94d6cda 100644 --- a/src/api/order.ts +++ b/src/api/order.ts @@ -1,39 +1,14 @@ import type { ApiResponse } from "@/lib/api" +import type { + CreateOrderData, + CreateOrderRequest, + CreateTestAccountRequest, + OrderItem, + OrderQueryParams, +} from "@/lib/models/order" import { callByUser } from "./base" -export type CreateOrderRequest = { - PackageId: number - OrderType: number - Account: string - Pwd: string - ConnectCount: number - MinPostfix?: number - MaxPostfix?: number - CouponId?: number - UseAccountAmount?: number - OPayType: number - PayChannel?: number - Price?: number -} - -export type CreateTestAccountRequest = { - ProductId: number - PackageId: number - Account: string - Pwd: string -} - -export type OrderInfo = { - OrderNo: string - OtherPayAmount: number - CreateTime: string - PayType: number -} - -export type CreateOrderData = { - OrderInfo: OrderInfo - PayData: string -} +// ==================== 购买下单(php-api /product/*) ==================== export async function createOrder( data: CreateOrderRequest, @@ -62,3 +37,71 @@ export async function checkPayStatus( "GET", ) } + +// ==================== 管理端订单(.NET /api/course/v1/order/*) ==================== + +// 订单分页列表 +export async function fetchOrderPage( + params: OrderQueryParams, +): Promise> { + return await callByUser( + "/api/course/v1/order/Page", + params, + "GET", + ) +} + +// 退款订单分页列表 +export async function fetchRefundOrders( + params: OrderQueryParams, +): Promise> { + return await callByUser( + "/api/course/v1/order/RefundOrders", + params, + "GET", + ) +} + +// 优惠券订单分页列表 +export async function fetchCouponOrders( + params: OrderQueryParams, +): Promise> { + return await callByUser( + "/api/course/v1/order/CouponOrders", + params, + "GET", + ) +} + +// 订单统计(Statistics 接口返回统计结构,待后端确认字段后收窄类型) +export async function fetchOrderStatistics( + params: OrderQueryParams, +): Promise>> { + return await callByUser>( + "/api/course/v1/order/Statistics", + params, + "GET", + ) +} + +// 用户消费统计 +export async function fetchUserConsumeStatistics( + params: Record, +): Promise>> { + return await callByUser>( + "/api/course/v1/order/UserConsumeStatistics", + params, + "GET", + ) +} + +// 订单导出 +export async function exportOrders( + params: OrderQueryParams, +): Promise> { + return await callByUser( + "/api/course/v1/order/Export", + params, + "GET", + ) +} diff --git a/src/api/record.ts b/src/api/record.ts new file mode 100644 index 0000000..9a30e9a --- /dev/null +++ b/src/api/record.ts @@ -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 +> { + 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> { + 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(list) ? list : [], + } +} diff --git a/src/api/rosorder.ts b/src/api/rosorder.ts new file mode 100644 index 0000000..d29b834 --- /dev/null +++ b/src/api/rosorder.ts @@ -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, +): Promise> { + return await callByUser( + "/api/course/v1/order/RosOrders", + params, + "GET", + ) +} diff --git a/src/api/user.ts b/src/api/user.ts new file mode 100644 index 0000000..52e6e1b --- /dev/null +++ b/src/api/user.ts @@ -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> { + return await callByUser( + "/api/baseinfo/v1/user/Page", + params, + "GET", + ) +} + +export async function fetchUserSearch( + params: UserQueryParams, +): Promise> { + return await callByUser( + "/api/baseinfo/v1/user/Search", + params, + "GET", + ) +} + +export async function fetchAmountDetails( + params: Record, +): Promise>> { + return await callByUser>( + "/api/baseinfo/v1/user/AmountDetails", + params, + "GET", + ) +} + +export async function updateUserProfile( + data: Record, +): Promise> { + return await callByUser( + "/api/baseinfo/v1/user/UpdateProfile", + data, + ) +} diff --git a/src/api/verify.ts b/src/api/verify.ts index 71b1077..9e0cb44 100644 --- a/src/api/verify.ts +++ b/src/api/verify.ts @@ -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", diff --git a/src/api/whitelist.ts b/src/api/whitelist.ts new file mode 100644 index 0000000..b875377 --- /dev/null +++ b/src/api/whitelist.ts @@ -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> { + 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(list) ? list : [], + } +} + +export async function saveWhiteIp(ip: string): Promise> { + return await phpPost("/http/user/white_save", { + cookie: document.cookie, + ip, + }) +} + +export async function deleteWhiteIp( + id: number, +): Promise> { + return await phpPost("/http/user/white_delete", { + cookie: document.cookie, + id, + }) +} + +export async function lockWhiteIp( + id: number, + locked: boolean, +): Promise> { + return await phpPost("/http/user/white_lock", { + cookie: document.cookie, + id, + locked: locked ? 1 : 0, + }) +} + +export async function setWhiteLimitMax( + limit: number, +): Promise> { + return await phpPost("/http/user/white_limit_set_su", { + cookie: document.cookie, + limit, + }) +} diff --git a/src/components/composites/accountCell.tsx b/src/components/composites/accountCell.tsx new file mode 100644 index 0000000..b732fcc --- /dev/null +++ b/src/components/composites/accountCell.tsx @@ -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 {accounts} + + return ( + + + {cutAccount(accounts)} + + {accounts} + + ) +} diff --git a/src/home/ipLine/index.tsx b/src/home/ipLine/index.tsx index f57b8f4..b1427d5 100644 --- a/src/home/ipLine/index.tsx +++ b/src/home/ipLine/index.tsx @@ -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 => [ - 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) + const data = rows.map(r => [ + toText(r.name), + toText(r.city), + toText(r.supply), + toText(r.nasname), + toText(r.daikuan), + toText(r.online), + ]) + downloadCsv(`${currentName || "IP线路表"}.csv`, header, data) toast.success("导出成功") } diff --git a/src/home/product/buy.tsx b/src/home/product/buy.tsx index 83df9ae..7c1478c 100644 --- a/src/home/product/buy.tsx +++ b/src/home/product/buy.tsx @@ -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" diff --git a/src/home/product/buyStatic.tsx b/src/home/product/buyStatic.tsx index 18c99ba..32150ba 100644 --- a/src/home/product/buyStatic.tsx +++ b/src/home/product/buyStatic.tsx @@ -80,7 +80,7 @@ function OperatorSelect({ return (
- {operators.map((op) => ( + {operators.map(op => ( ))}
@@ -177,7 +179,7 @@ function BandwidthSelect({ return (
- {options.map((bw) => ( + {options.map(bw => (
) : (
-

专线独享:个人独享整个线路,真实独享,永远不重复。

+

+ 专线独享:个人独享整个线路,真实独享,永远不重复。 +

线路切换单日限制为20次

如果没有自己想用的地区线路或线路余量不足,请联系客服上架 @@ -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)} />

{/* 余额 */} @@ -520,4 +531,4 @@ export default function BuyStaticPage() { ) -} \ No newline at end of file +} diff --git a/src/home/product/index.tsx b/src/home/product/index.tsx index 5d886b2..2115d9f 100644 --- a/src/home/product/index.tsx +++ b/src/home/product/index.tsx @@ -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 diff --git a/src/home/product/purchaseInfo.tsx b/src/home/product/purchaseInfo.tsx index dbe5315..2381734 100644 --- a/src/home/product/purchaseInfo.tsx +++ b/src/home/product/purchaseInfo.tsx @@ -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() {