修复动静态ip标题跳转问题 & 添加admin页面
This commit is contained in:
1
.env
1
.env
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
|
VITE_PHP_API_BASE_URL=https://php-api.juip.com
|
||||||
VITE_CLIENT_ID=web
|
VITE_CLIENT_ID=web
|
||||||
VITE_CLIENT_SECRET=web
|
VITE_CLIENT_SECRET=web
|
||||||
3
bun.lock
3
bun.lock
@@ -12,6 +12,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.5.0",
|
"radix-ui": "^1.5.0",
|
||||||
@@ -547,6 +548,8 @@
|
|||||||
|
|
||||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||||
|
|
||||||
|
"date-fns": ["date-fns@4.4.0", "https://registry.npmmirror.com/date-fns/-/date-fns-4.4.0.tgz", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "https://registry.npmmirror.com/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@tanstack/react-table": "^8.21.3",
|
"@tanstack/react-table": "^8.21.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.4.0",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"radix-ui": "^1.5.0",
|
"radix-ui": "^1.5.0",
|
||||||
|
|||||||
@@ -156,13 +156,13 @@ export function Navbar() {
|
|||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/channels"
|
href="/admin/orders"
|
||||||
icon={<ShoppingBag size={20} />}
|
icon={<ShoppingBag size={20} />}
|
||||||
label="订单管理"
|
label="订单管理"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/channels"
|
href="/admin/refundOrders"
|
||||||
icon={<ClipboardList size={20} />}
|
icon={<ClipboardList size={20} />}
|
||||||
label="退货管理"
|
label="退货管理"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
@@ -180,31 +180,31 @@ export function Navbar() {
|
|||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/record"
|
href="/admin/httpRecharge"
|
||||||
icon={<Archive size={20} />}
|
icon={<Archive size={20} />}
|
||||||
label="充值记录"
|
label="充值记录"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/resources_short"
|
href="/admin/resources/short"
|
||||||
icon={<Clock1 size={20} />}
|
icon={<Clock1 size={20} />}
|
||||||
label="短效套餐"
|
label="短效套餐"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/resources_long"
|
href="/admin/resources/long"
|
||||||
icon={<CalendarClock size={20} />}
|
icon={<CalendarClock size={20} />}
|
||||||
label="长效管理"
|
label="长效管理"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/channels"
|
href="/admin/rosorder"
|
||||||
icon={<Router size={20} />}
|
icon={<Router size={20} />}
|
||||||
label="软路由"
|
label="软路由"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
/>
|
/>
|
||||||
<NavItem
|
<NavItem
|
||||||
href="/admin/channels"
|
href="/admin/record"
|
||||||
icon={<ClipboardClock size={20} />}
|
icon={<ClipboardClock size={20} />}
|
||||||
label="使用记录"
|
label="使用记录"
|
||||||
expand={navbar}
|
expand={navbar}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { EditInfoDialog } from "@/components/composites/dialogs/editInfoDialog"
|
import { EditInfoDialog, type EditInfoValues } from "@/components/composites/dialogs/editInfoDialog"
|
||||||
import DataTable from "@/components/data-table"
|
import DataTable from "@/components/data-table"
|
||||||
import Page from "@/components/page"
|
import Page from "@/components/page"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -90,7 +90,7 @@ export default function DashboardPage() {
|
|||||||
yearConsumption: 2365,
|
yearConsumption: 2365,
|
||||||
}
|
}
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||||
const handleSaveInfo = (data: any) => {
|
const handleSaveInfo = (data: EditInfoValues) => {
|
||||||
console.log("保存的信息:", data)
|
console.log("保存的信息:", data)
|
||||||
// 这里调 API 保存数据
|
// 这里调 API 保存数据
|
||||||
}
|
}
|
||||||
@@ -300,11 +300,14 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 账号信息 */}
|
{/* 账号信息 */}
|
||||||
<div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col">
|
<div className="md:col-start-4 md:row-start-1 md:row-span-2 bg-white rounded-lg p-4 flex flex-col min-h-0">
|
||||||
<h3 className="font-semibold text-lg shrink-0">账号信息</h3>
|
<h3 className="font-semibold text-lg shrink-0">账号信息</h3>
|
||||||
<div className="mt-4 flex justify-between items-start gap-3 flex-auto">
|
<div className="mt-4 flex flex-col sm:flex-row justify-between items-start gap-4 flex-1 min-h-0">
|
||||||
<div className="flex flex-col items-center gap-4 shrink-0">
|
{/* 头像/二维码区域 */}
|
||||||
<span className="text-base font-medium">{accountInfo.phone}</span>
|
<div className="flex flex-col items-center gap-3 shrink-0 w-full sm:w-auto">
|
||||||
|
<span className="text-base font-medium truncate w-full text-center sm:text-left">
|
||||||
|
{accountInfo.phone}
|
||||||
|
</span>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -312,49 +315,51 @@ export default function DashboardPage() {
|
|||||||
>
|
>
|
||||||
去实名
|
去实名
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-20 h-20 border border-gray-200 rounded">
|
<div className="w-20 h-20 border border-gray-200 rounded shrink-0 overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src="/qrcode-placeholder.png"
|
src="/qrcode-placeholder.png"
|
||||||
alt="账号二维码"
|
alt="账号二维码"
|
||||||
className="w-full h-full object-contain"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2.5 text-sm flex-1">
|
{/* 信息列表 */}
|
||||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
<div className="flex flex-col gap-2 text-sm flex-1 min-w-0 w-full">
|
||||||
<span className="text-gray-400 w-20 shrink-0">密码</span>
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||||
<span className="text-gray-700 font-mono tracking-wide">
|
<span className="text-gray-400 w-14 shrink-0">密码</span>
|
||||||
|
<span className="text-gray-700 font-mono tracking-wide truncate">
|
||||||
********
|
********
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||||
<span className="text-gray-400 w-20 shrink-0">QQ</span>
|
<span className="text-gray-400 w-14 shrink-0">QQ</span>
|
||||||
<span className="text-gray-700">
|
<span className="text-gray-700 truncate">
|
||||||
{accountInfo.qq || "未绑定"}
|
{accountInfo.qq || "未绑定"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||||
<span className="text-gray-400 w-20 shrink-0">微信号</span>
|
<span className="text-gray-400 w-14 shrink-0">微信号</span>
|
||||||
<span className="text-gray-700">
|
<span className="text-gray-700 truncate">
|
||||||
{accountInfo.wechat || "未绑定"}
|
{accountInfo.wechat || "未绑定"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||||
<span className="text-gray-400 w-20 shrink-0">淘宝会员</span>
|
<span className="text-gray-400 w-14 shrink-0">淘宝</span>
|
||||||
<span className="text-gray-700">
|
<span className="text-gray-700 truncate">
|
||||||
{accountInfo.taobao || "未绑定"}
|
{accountInfo.taobao || "未绑定"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start py-1 border-b border-gray-50 last:border-0">
|
<div className="flex items-start py-1 border-b border-gray-50 last:border-0 min-h-7">
|
||||||
<span className="text-gray-400 w-20 shrink-0">邮箱</span>
|
<span className="text-gray-400 w-14 shrink-0">邮箱</span>
|
||||||
<span className="text-gray-700 truncate">
|
<span className="text-gray-700 truncate">
|
||||||
{accountInfo.email || "未绑定"}
|
{accountInfo.email || "未绑定"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end shrink-0">
|
|
||||||
|
<div className="mt-4 flex justify-end shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -1,5 +1,29 @@
|
|||||||
|
import DataTable from "@/components/data-table"
|
||||||
import Page from "@/components/page"
|
import Page from "@/components/page"
|
||||||
|
|
||||||
export default function FundsPage() {
|
export default function FundsPage() {
|
||||||
return <Page>资金明细</Page>
|
return (
|
||||||
|
<Page>
|
||||||
|
<DataTable
|
||||||
|
data={[]}
|
||||||
|
status="done"
|
||||||
|
columns={[
|
||||||
|
{ accessorKey: "date", header: "交易时间" },
|
||||||
|
{ accessorKey: "product", header: "用户" },
|
||||||
|
{ accessorKey: "plan", header: "资金去向" },
|
||||||
|
{
|
||||||
|
accessorKey: "price",
|
||||||
|
header: "金额",
|
||||||
|
},
|
||||||
|
{ accessorKey: "connections", header: "操作前约(元)" },
|
||||||
|
{ accessorKey: "account", header: "备注" },
|
||||||
|
]}
|
||||||
|
pagination={{
|
||||||
|
page: 0,
|
||||||
|
size: 10,
|
||||||
|
total: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/admin/httpRecharge/page.tsx
Normal file
32
src/admin/httpRecharge/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import DataTable from "@/components/data-table"
|
||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function HttpRechargePage() {
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<h3 className="font-semibold text-lg">HTTP 充值记录</h3>
|
||||||
|
<DataTable
|
||||||
|
data={[]}
|
||||||
|
status="done"
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
accessorKey: "create_time",
|
||||||
|
header: "交易时间",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ju_money",
|
||||||
|
header: "充值H币",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "pay_type",
|
||||||
|
header: "充值渠道",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "pay_money",
|
||||||
|
header: "充值金额",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
401
src/admin/orders/mock.ts
Normal file
401
src/admin/orders/mock.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
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" },
|
||||||
|
]
|
||||||
290
src/admin/orders/page.tsx
Normal file
290
src/admin/orders/page.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import DataTable from "@/components/data-table"
|
||||||
|
import Page from "@/components/page"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import {
|
||||||
|
cutAccount,
|
||||||
|
DEFAULT_FILTERS,
|
||||||
|
formatOrderType,
|
||||||
|
ORDER_TYPE_LABELS,
|
||||||
|
type OrderFilters,
|
||||||
|
type OrderItem,
|
||||||
|
PACKAGE_OPTIONS,
|
||||||
|
totalConnections,
|
||||||
|
} from "@/lib/models/order"
|
||||||
|
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||||
|
|
||||||
|
function formatMoney(value: number): string {
|
||||||
|
return value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountCell({ accounts }: { accounts: string }) {
|
||||||
|
const isCut = accounts.length > 15
|
||||||
|
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<OrderItem>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "CreateTime",
|
||||||
|
header: "日期",
|
||||||
|
cell: ({ row }) => formatDate(row.original.CreateTime),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "OrderNo",
|
||||||
|
header: "订单编号",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "OrderType",
|
||||||
|
header: "类型",
|
||||||
|
cell: ({ row }) => formatOrderType(row.original.OrderType),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ProductName",
|
||||||
|
header: "产品",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "PackageName",
|
||||||
|
header: "套餐",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "DayPrice",
|
||||||
|
header: "单价",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.DayPrice),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ConnectCount",
|
||||||
|
header: "总连接数",
|
||||||
|
cell: ({ row }) => totalConnections(row.original),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Accounts",
|
||||||
|
header: "账号",
|
||||||
|
cell: ({ row }) => <AccountCell accounts={row.original.Accounts} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "OrderAmount",
|
||||||
|
header: "订单金额",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.OrderAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "CouponAmount",
|
||||||
|
header: "优惠券",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.CouponAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "PaymentAmount",
|
||||||
|
header: "实付金额",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.PaymentAmount),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||||
|
const keyword = filters.keyword.trim()
|
||||||
|
|
||||||
|
return list.filter(order => {
|
||||||
|
if (
|
||||||
|
filters.orderType !== "0" &&
|
||||||
|
order.OrderType !== Number(filters.orderType)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
filters.productId !== "0" &&
|
||||||
|
order.ProductId !== Number(filters.productId)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
filters.packageName !== "0" &&
|
||||||
|
order.PackageName !== filters.packageName
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
keyword &&
|
||||||
|
!order.OrderNo.includes(keyword) &&
|
||||||
|
!order.Accounts.includes(keyword)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrdersPage() {
|
||||||
|
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||||
|
const [appliedFilters, setAppliedFilters] =
|
||||||
|
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||||
|
[appliedFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const paged = useMemo(
|
||||||
|
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||||
|
[filtered, page, pageSize],
|
||||||
|
)
|
||||||
|
|
||||||
|
const patch = (partial: Partial<OrderFilters>) =>
|
||||||
|
setFilters({ ...filters, ...partial })
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setAppliedFilters(filters)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||||
|
日期筛选:
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.bTime}
|
||||||
|
onChange={e => patch({ bTime: e.target.value })}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="开始时间"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">至</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.eTime}
|
||||||
|
onChange={e => patch({ eTime: e.target.value })}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="结束时间"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.orderType}
|
||||||
|
onValueChange={value => patch({ orderType: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-30">
|
||||||
|
<SelectValue placeholder="全部类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">全部类型</SelectItem>
|
||||||
|
{Object.entries(ORDER_TYPE_LABELS).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.productId}
|
||||||
|
onValueChange={value => patch({ productId: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="全部产品" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">全部产品</SelectItem>
|
||||||
|
{MOCK_PRODUCTS.map(product => (
|
||||||
|
<SelectItem key={product.id} value={String(product.id)}>
|
||||||
|
{product.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.packageName}
|
||||||
|
onValueChange={value => patch({ packageName: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="全部套餐" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">全部套餐</SelectItem>
|
||||||
|
{PACKAGE_OPTIONS.map(pkg => (
|
||||||
|
<SelectItem key={pkg} value={pkg}>
|
||||||
|
{pkg}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={filters.keyword}
|
||||||
|
onChange={e => patch({ keyword: e.target.value })}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === "Enter") handleSearch()
|
||||||
|
}}
|
||||||
|
placeholder="账号"
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch}>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
<h3 className="font-semibold text-lg">订单管理</h3>
|
||||||
|
<DataTable
|
||||||
|
data={paged}
|
||||||
|
status="done"
|
||||||
|
columns={columns}
|
||||||
|
pagination={{
|
||||||
|
page: page - 1,
|
||||||
|
size: pageSize,
|
||||||
|
total: filtered.length,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSizeChange: size => {
|
||||||
|
setPageSize(size)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function LongPage() {
|
|
||||||
return <div>LongPage</div>
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export default function ShortPage() {
|
|
||||||
return <div>ShortPage</div>
|
|
||||||
}
|
|
||||||
40
src/admin/record/mock.ts
Normal file
40
src/admin/record/mock.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { HttpUsedLogItem, HttpUsedSummary } from "@/lib/models/record"
|
||||||
|
|
||||||
|
export const MOCK_USED_SUMMARY: HttpUsedSummary = {
|
||||||
|
todayShortPackIpUsed: 3,
|
||||||
|
shortPackIpUsed: 45,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PACK_IP_PREFIX = [
|
||||||
|
{ packType: 21, prefix: "211.90.13" },
|
||||||
|
{ packType: 22, prefix: "112.65.28" },
|
||||||
|
]
|
||||||
|
|
||||||
|
function pad(value: number): string {
|
||||||
|
return String(value).padStart(2, "0")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogs(count: number): HttpUsedLogItem[] {
|
||||||
|
const now = new Date()
|
||||||
|
const logs: HttpUsedLogItem[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const source = PACK_IP_PREFIX[i % PACK_IP_PREFIX.length]
|
||||||
|
const time = new Date(now.getTime() - i * 37 * 60 * 1000)
|
||||||
|
const createTime =
|
||||||
|
`${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())} ` +
|
||||||
|
`${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`
|
||||||
|
|
||||||
|
logs.push({
|
||||||
|
packType: source.packType,
|
||||||
|
userIp: `113.118.${(i % 200) + 10}.${(i % 250) + 3}`,
|
||||||
|
ip: `${source.prefix}.${(i % 240) + 10}`,
|
||||||
|
port: 6000 + (i % 300),
|
||||||
|
createTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_USED_LOGS = buildLogs(56)
|
||||||
136
src/admin/record/page.tsx
Normal file
136
src/admin/record/page.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import DataTable from "@/components/data-table"
|
||||||
|
import Page from "@/components/page"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
formatPackType,
|
||||||
|
HTTP_USE_PACK_TYPE_OPTIONS,
|
||||||
|
type HttpUsedLogItem,
|
||||||
|
} from "@/lib/models/record"
|
||||||
|
import { MOCK_USED_LOGS, MOCK_USED_SUMMARY } from "./mock"
|
||||||
|
|
||||||
|
const columns: ColumnDef<HttpUsedLogItem>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "packType",
|
||||||
|
header: "使用类型",
|
||||||
|
cell: ({ row }) => formatPackType(row.original.packType),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "userIp",
|
||||||
|
header: "客户端IP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ip",
|
||||||
|
header: "使用IP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "port",
|
||||||
|
header: "端口号",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "createTime",
|
||||||
|
header: "使用时间",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function StatCard(props: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
accent: "amber" | "blue"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg p-4 flex flex-col gap-1 ${
|
||||||
|
props.accent === "amber" ? "bg-amber-100" : "bg-blue-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-600">{props.label}</span>
|
||||||
|
<span className="text-2xl font-semibold">{props.value} 个</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecordPage() {
|
||||||
|
const [packType, setPackType] = useState(HTTP_USE_PACK_TYPE_OPTIONS[0])
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => MOCK_USED_LOGS.filter(log => log.packType === packType),
|
||||||
|
[packType],
|
||||||
|
)
|
||||||
|
|
||||||
|
const paged = useMemo(
|
||||||
|
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||||
|
[filtered, page, pageSize],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handlePackTypeChange = (value: string) => {
|
||||||
|
setPackType(Number(value))
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
<h3 className="font-semibold text-lg">包天包量使用概况</h3>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="当日使用IP数量"
|
||||||
|
value={MOCK_USED_SUMMARY.todayShortPackIpUsed}
|
||||||
|
accent="amber"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="累计使用IP数量"
|
||||||
|
value={MOCK_USED_SUMMARY.shortPackIpUsed}
|
||||||
|
accent="blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-orange-600">
|
||||||
|
明细 最多允许查看72小时内记录(短效无限量套餐暂无记录)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Select value={String(packType)} onValueChange={handlePackTypeChange}>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{HTTP_USE_PACK_TYPE_OPTIONS.map(type => (
|
||||||
|
<SelectItem key={type} value={String(type)}>
|
||||||
|
{formatPackType(type)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
data={paged}
|
||||||
|
status="done"
|
||||||
|
columns={columns}
|
||||||
|
pagination={{
|
||||||
|
page,
|
||||||
|
size: pageSize,
|
||||||
|
total: filtered.length,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSizeChange: size => {
|
||||||
|
setPageSize(size)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
401
src/admin/refundOrders/mock.ts
Normal file
401
src/admin/refundOrders/mock.ts
Normal file
@@ -0,0 +1,401 @@
|
|||||||
|
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" },
|
||||||
|
]
|
||||||
272
src/admin/refundOrders/page.tsx
Normal file
272
src/admin/refundOrders/page.tsx
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
import { useMemo, useState } from "react"
|
||||||
|
import DataTable from "@/components/data-table"
|
||||||
|
import Page from "@/components/page"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import {
|
||||||
|
cutAccount,
|
||||||
|
DEFAULT_FILTERS,
|
||||||
|
formatOrderType,
|
||||||
|
type OrderFilters,
|
||||||
|
type OrderItem,
|
||||||
|
PACKAGE_OPTIONS,
|
||||||
|
totalConnections,
|
||||||
|
} from "@/lib/models/order"
|
||||||
|
import { MOCK_ORDERS, MOCK_PRODUCTS } from "./mock"
|
||||||
|
|
||||||
|
function formatMoney(value: number): string {
|
||||||
|
return value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string): string {
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0")
|
||||||
|
const day = String(date.getDate()).padStart(2, "0")
|
||||||
|
return `${year}.${month}.${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountCell({ accounts }: { accounts: string }) {
|
||||||
|
const isCut = accounts.length > 15
|
||||||
|
if (!isCut) return <span className="truncate">{accounts}</span>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<span className="cursor-default truncate">{cutAccount(accounts)}</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent className="max-w-80 break-all">{accounts}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnDef<OrderItem>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "CreateTime",
|
||||||
|
header: "日期",
|
||||||
|
cell: ({ row }) => formatDate(row.original.CreateTime),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "OrderNo",
|
||||||
|
header: "订单编号",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "OrderType",
|
||||||
|
header: "类型",
|
||||||
|
cell: ({ row }) => formatOrderType(row.original.OrderType),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ProductName",
|
||||||
|
header: "产品",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "PackageName",
|
||||||
|
header: "套餐",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "Accounts",
|
||||||
|
header: "账号",
|
||||||
|
cell: ({ row }) => <AccountCell accounts={row.original.Accounts} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "ConnectCount",
|
||||||
|
header: "连接数",
|
||||||
|
cell: ({ row }) => totalConnections(row.original),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "DayPrice",
|
||||||
|
header: "退款时长",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.DayPrice),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "DayPrice",
|
||||||
|
header: "退款单价",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.DayPrice),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "PaymentAmount",
|
||||||
|
header: "实付金额",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.PaymentAmount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "CouponAmount",
|
||||||
|
header: "退款金额",
|
||||||
|
cell: ({ row }) => formatMoney(row.original.CouponAmount),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function matchOrders(list: typeof MOCK_ORDERS, filters: OrderFilters) {
|
||||||
|
const keyword = filters.keyword.trim()
|
||||||
|
|
||||||
|
return list.filter(order => {
|
||||||
|
if (
|
||||||
|
filters.orderType !== "0" &&
|
||||||
|
order.OrderType !== Number(filters.orderType)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
filters.productId !== "0" &&
|
||||||
|
order.ProductId !== Number(filters.productId)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
filters.packageName !== "0" &&
|
||||||
|
order.PackageName !== filters.packageName
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
keyword &&
|
||||||
|
!order.OrderNo.includes(keyword) &&
|
||||||
|
!order.Accounts.includes(keyword)
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.bTime && order.CreateTime.slice(0, 10) < filters.bTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (filters.eTime && order.CreateTime.slice(0, 10) > filters.eTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RefundOrdersPage() {
|
||||||
|
const [filters, setFilters] = useState<OrderFilters>(DEFAULT_FILTERS)
|
||||||
|
const [appliedFilters, setAppliedFilters] =
|
||||||
|
useState<OrderFilters>(DEFAULT_FILTERS)
|
||||||
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(10)
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => matchOrders(MOCK_ORDERS, appliedFilters),
|
||||||
|
[appliedFilters],
|
||||||
|
)
|
||||||
|
|
||||||
|
const paged = useMemo(
|
||||||
|
() => filtered.slice((page - 1) * pageSize, page * pageSize),
|
||||||
|
[filtered, page, pageSize],
|
||||||
|
)
|
||||||
|
|
||||||
|
const patch = (partial: Partial<OrderFilters>) =>
|
||||||
|
setFilters({ ...filters, ...partial })
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setAppliedFilters(filters)
|
||||||
|
setPage(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Page>
|
||||||
|
<div className="bg-white rounded-lg p-4 flex flex-wrap items-end gap-x-4 gap-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-gray-500 whitespace-nowrap">
|
||||||
|
日期筛选:
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.bTime}
|
||||||
|
onChange={e => patch({ bTime: e.target.value })}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="开始时间"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-400">至</span>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={filters.eTime}
|
||||||
|
onChange={e => patch({ eTime: e.target.value })}
|
||||||
|
className="w-36"
|
||||||
|
aria-label="结束时间"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.productId}
|
||||||
|
onValueChange={value => patch({ productId: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="全部产品" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">全部产品</SelectItem>
|
||||||
|
{MOCK_PRODUCTS.map(product => (
|
||||||
|
<SelectItem key={product.id} value={String(product.id)}>
|
||||||
|
{product.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.packageName}
|
||||||
|
onValueChange={value => patch({ packageName: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="全部套餐" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">全部套餐</SelectItem>
|
||||||
|
{PACKAGE_OPTIONS.map(pkg => (
|
||||||
|
<SelectItem key={pkg} value={pkg}>
|
||||||
|
{pkg}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={filters.keyword}
|
||||||
|
onChange={e => patch({ keyword: e.target.value })}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === "Enter") handleSearch()
|
||||||
|
}}
|
||||||
|
placeholder="账号"
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
<Button onClick={handleSearch}>
|
||||||
|
<SearchIcon data-icon="inline-start" />
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg p-4 flex flex-col gap-4">
|
||||||
|
<h3 className="font-semibold text-lg">订单管理</h3>
|
||||||
|
<DataTable
|
||||||
|
data={paged}
|
||||||
|
status="done"
|
||||||
|
columns={columns}
|
||||||
|
pagination={{
|
||||||
|
page: page - 1,
|
||||||
|
size: pageSize,
|
||||||
|
total: filtered.length,
|
||||||
|
onPageChange: setPage,
|
||||||
|
onSizeChange: size => {
|
||||||
|
setPageSize(size)
|
||||||
|
setPage(1)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Page>
|
||||||
|
)
|
||||||
|
}
|
||||||
5
src/admin/resources/long.tsx
Normal file
5
src/admin/resources/long.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function LongPage() {
|
||||||
|
return <Page>长效管理</Page>
|
||||||
|
}
|
||||||
5
src/admin/resources/short.tsx
Normal file
5
src/admin/resources/short.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function ShortPage() {
|
||||||
|
return <Page>短效套餐</Page>
|
||||||
|
}
|
||||||
5
src/admin/rosorder/page.tsx
Normal file
5
src/admin/rosorder/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import Page from "@/components/page"
|
||||||
|
|
||||||
|
export default function RosorderPage() {
|
||||||
|
return <Page>软路由页面</Page>
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { ApiResponse } from "@/lib/api"
|
import type { ApiResponse } from "@/lib/api"
|
||||||
import { callPublic } from "./base"
|
|
||||||
|
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 = {
|
export type LineItem = {
|
||||||
city: string | number
|
city: string | number
|
||||||
@@ -20,24 +24,50 @@ export type LineSearchResult = {
|
|||||||
data: LineItem[]
|
data: LineItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchLineData(
|
async function getJson<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
product: number,
|
const controller = new AbortController()
|
||||||
): Promise<ApiResponse<LineData>> {
|
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||||
return await callPublic<LineData>(
|
try {
|
||||||
`/script/linedata/display.php?product=${product}`,
|
const response = await fetch(url, { signal: controller.signal })
|
||||||
undefined,
|
if (!response.ok) {
|
||||||
"GET",
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: "请求失败",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = (await response.json()) as T
|
||||||
|
return { success: true, data }
|
||||||
|
} catch (e) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 408,
|
||||||
|
message: "请求超时,请稍后重试",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: 500,
|
||||||
|
message: (e as Error).message || "网络错误",
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchLineData(product: number): Promise<ApiResponse<LineData>> {
|
||||||
|
return getJson<LineData>(
|
||||||
|
`${PHP_API_BASE_URL}/script/linedata/display.php?product=${product}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchLineData(
|
export function searchLineData(
|
||||||
productid: number,
|
productid: number,
|
||||||
info: string,
|
info: string,
|
||||||
): Promise<ApiResponse<LineSearchResult>> {
|
): Promise<ApiResponse<LineSearchResult>> {
|
||||||
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
||||||
return await callPublic<LineSearchResult>(
|
return getJson<LineSearchResult>(
|
||||||
`/script/linedata/search.php?${query}`,
|
`${PHP_API_BASE_URL}/script/linedata/search.php?${query}`,
|
||||||
undefined,
|
|
||||||
"GET",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,23 @@ export async function fetchProducts(): Promise<ApiResponse<ProductItem[]>> {
|
|||||||
"GET",
|
"GET",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchStaticProducts(): Promise<
|
||||||
|
ApiResponse<ProductItem[]>
|
||||||
|
> {
|
||||||
|
return await callPublic<ProductItem[]>(
|
||||||
|
"/product/ApiProductStatic",
|
||||||
|
undefined,
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWindowProducts(): Promise<
|
||||||
|
ApiResponse<ProductItem[]>
|
||||||
|
> {
|
||||||
|
return await callPublic<ProductItem[]>(
|
||||||
|
"/product/ApiProductWindow",
|
||||||
|
undefined,
|
||||||
|
"GET",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { Eye, EyeOff } from "lucide-react"
|
import { Eye, EyeOff } from "lucide-react"
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -7,45 +10,70 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
export type EditInfoValues = {
|
||||||
|
username: string
|
||||||
|
taobao: string
|
||||||
|
password: string
|
||||||
|
email: string
|
||||||
|
wechat: string
|
||||||
|
qq: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const editInfoSchema = z.object({
|
||||||
|
username: z.string().trim().min(1, "请输入用户名"),
|
||||||
|
taobao: z.string(),
|
||||||
|
password: z.string().min(1, "请输入密码"),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.refine(
|
||||||
|
v => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
|
||||||
|
"请输入正确的邮箱",
|
||||||
|
),
|
||||||
|
wechat: z.string(),
|
||||||
|
qq: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
interface EditInfoDialogProps {
|
interface EditInfoDialogProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
initialData?: {
|
initialData?: EditInfoValues
|
||||||
username: string
|
onSave?: (data: EditInfoValues) => void
|
||||||
taobao: string
|
}
|
||||||
password: string
|
|
||||||
email: string
|
const DEFAULT_INITIAL_DATA: EditInfoValues = {
|
||||||
wechat: string
|
username: "191****1234",
|
||||||
qq: string
|
taobao: "191****1234",
|
||||||
}
|
password: "********",
|
||||||
onSave?: (data: any) => void
|
email: "123456123@qq.com",
|
||||||
|
wechat: "",
|
||||||
|
qq: "",
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditInfoDialog({
|
export function EditInfoDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
initialData = {
|
initialData = DEFAULT_INITIAL_DATA,
|
||||||
username: "191****1234",
|
|
||||||
taobao: "191****1234",
|
|
||||||
password: "********",
|
|
||||||
email: "123456123@qq.com",
|
|
||||||
wechat: "",
|
|
||||||
qq: "",
|
|
||||||
},
|
|
||||||
onSave,
|
onSave,
|
||||||
}: EditInfoDialogProps) {
|
}: EditInfoDialogProps) {
|
||||||
const [formData, setFormData] = useState(initialData)
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
|
||||||
const handleChange = (field: string, value: string) => {
|
const form = useForm<EditInfoValues>({
|
||||||
setFormData(prev => ({ ...prev, [field]: value }))
|
resolver: zodResolver(editInfoSchema),
|
||||||
}
|
defaultValues: initialData,
|
||||||
|
})
|
||||||
|
|
||||||
const handleSubmit = () => {
|
useEffect(() => {
|
||||||
onSave?.(formData)
|
if (open) {
|
||||||
|
form.reset(initialData)
|
||||||
|
}
|
||||||
|
}, [open, initialData, form])
|
||||||
|
|
||||||
|
const onSubmit = (data: EditInfoValues) => {
|
||||||
|
onSave?.(data)
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,91 +86,123 @@ export function EditInfoDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 mt-2">
|
<form className="space-y-4 mt-2" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
{/* 用户名 */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm text-gray-600">用户名</Label>
|
<Label className="text-sm text-gray-600">用户名</Label>
|
||||||
<Input
|
<Controller
|
||||||
value={formData.username}
|
name="username"
|
||||||
onChange={e => handleChange("username", e.target.value)}
|
control={form.control}
|
||||||
className="h-11 bg-gray-50 border-gray-200"
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 淘宝会员名 */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm text-gray-600">淘宝会员名</Label>
|
<Label className="text-sm text-gray-600">淘宝会员名</Label>
|
||||||
<Input
|
<Controller
|
||||||
value={formData.taobao}
|
name="taobao"
|
||||||
onChange={e => handleChange("taobao", e.target.value)}
|
control={form.control}
|
||||||
className="h-11 bg-gray-50 border-gray-200"
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 密码 */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm text-gray-600">密码</Label>
|
<Label className="text-sm text-gray-600">密码</Label>
|
||||||
<div className="relative">
|
<Controller
|
||||||
<Input
|
name="password"
|
||||||
type={showPassword ? "text" : "password"}
|
control={form.control}
|
||||||
value={formData.password}
|
render={({ field, fieldState }) => (
|
||||||
onChange={e => handleChange("password", e.target.value)}
|
<>
|
||||||
className="h-11 bg-gray-50 border-gray-200 pr-10"
|
<div className="relative">
|
||||||
/>
|
<Input
|
||||||
<button
|
{...field}
|
||||||
type="button"
|
type={showPassword ? "text" : "password"}
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
className="h-11 bg-gray-50 border-gray-200 pr-10"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
/>
|
||||||
>
|
<button
|
||||||
{showPassword ? (
|
type="button"
|
||||||
<EyeOff className="h-4 w-4" />
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
) : (
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
<Eye className="h-4 w-4" />
|
>
|
||||||
)}
|
{showPassword ? (
|
||||||
</button>
|
<EyeOff className="h-4 w-4" />
|
||||||
</div>
|
) : (
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 邮箱 */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm text-gray-600">邮箱</Label>
|
<Label className="text-sm text-gray-600">邮箱</Label>
|
||||||
<Input
|
<Controller
|
||||||
value={formData.email}
|
name="email"
|
||||||
onChange={e => handleChange("email", e.target.value)}
|
control={form.control}
|
||||||
className="h-11 bg-gray-50 border-gray-200"
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 微信 */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm text-gray-600">微信</Label>
|
<Label className="text-sm text-gray-600">微信</Label>
|
||||||
<Input
|
<Controller
|
||||||
value={formData.wechat}
|
name="wechat"
|
||||||
onChange={e => handleChange("wechat", e.target.value)}
|
control={form.control}
|
||||||
placeholder="请输入"
|
render={({ field }) => (
|
||||||
className="h-11 bg-gray-50 border-gray-200"
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="请输入"
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QQ */}
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label className="text-sm text-gray-600">QQ</Label>
|
<Label className="text-sm text-gray-600">QQ</Label>
|
||||||
<Input
|
<Controller
|
||||||
value={formData.qq}
|
name="qq"
|
||||||
onChange={e => handleChange("qq", e.target.value)}
|
control={form.control}
|
||||||
placeholder="请输入"
|
render={({ field }) => (
|
||||||
className="h-11 bg-gray-50 border-gray-200"
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="请输入"
|
||||||
|
className="h-11 bg-gray-50 border-gray-200"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 保存按钮 */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
type="submit"
|
||||||
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
|
||||||
>
|
>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ export type DataTableProps<T> = {
|
|||||||
headRow?: string
|
headRow?: string
|
||||||
dataRow?: string
|
dataRow?: string
|
||||||
}
|
}
|
||||||
/** 是否开启行选择复选框 */
|
|
||||||
enableSelection?: boolean
|
enableSelection?: boolean
|
||||||
/** 受控选中状态 */
|
|
||||||
rowSelection?: Record<string, boolean>
|
rowSelection?: Record<string, boolean>
|
||||||
/** 选中状态变化回调 */
|
|
||||||
onSelectionChange?: (selection: Record<string, boolean>) => void
|
onSelectionChange?: (selection: Record<string, boolean>) => void
|
||||||
/** 行唯一ID生成函数 */
|
|
||||||
getRowId?: (row: T, index: number) => string
|
getRowId?: (row: T, index: number) => string
|
||||||
|
stickyHeader?: boolean
|
||||||
|
maxHeight?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataTable<T extends Record<string, unknown>>(
|
export default function DataTable<T extends Record<string, unknown>>(
|
||||||
@@ -112,8 +110,18 @@ export default function DataTable<T extends Record<string, unknown>>(
|
|||||||
<>
|
<>
|
||||||
{/* 数据表 */}
|
{/* 数据表 */}
|
||||||
<div className="rounded-md relative bg-card">
|
<div className="rounded-md relative bg-card">
|
||||||
<TableRoot>
|
<TableRoot
|
||||||
<TableHeader>
|
containerClassName={
|
||||||
|
props.stickyHeader
|
||||||
|
? cn("overflow-y-auto", props.maxHeight ?? "max-h-[600px]")
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableHeader
|
||||||
|
className={
|
||||||
|
props.stickyHeader ? "sticky top-0 z-10 bg-card" : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{table.getHeaderGroups().map(group => (
|
{table.getHeaderGroups().map(group => (
|
||||||
<TableRow key={group.id} className={props.classNames?.headRow}>
|
<TableRow key={group.id} className={props.classNames?.headRow}>
|
||||||
{group.headers.map(header => (
|
{group.headers.map(header => (
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function ProductDropdown({
|
|||||||
title="动静态IP"
|
title="动静态IP"
|
||||||
desc="千万级动态短效IP,自动去重,高并发采集"
|
desc="千万级动态短效IP,自动去重,高并发采集"
|
||||||
tag="热门"
|
tag="热门"
|
||||||
onClick={() => handleClick("/product?tab=short")}
|
onClick={() => handleClick("/product")}
|
||||||
/>
|
/>
|
||||||
<ProductCard
|
<ProductCard
|
||||||
size={s}
|
size={s}
|
||||||
@@ -87,7 +87,7 @@ export default function ProductDropdown({
|
|||||||
>
|
>
|
||||||
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
|
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleClick("/product?tab=short")}
|
onClick={() => handleClick("/product")}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-blue-600 hover:underline whitespace-nowrap",
|
"text-blue-600 hover:underline whitespace-nowrap",
|
||||||
s.footerLink,
|
s.footerLink,
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ import type * as React from "react"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils/index"
|
import { cn } from "@/lib/utils/index"
|
||||||
|
|
||||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
function Table({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="table-container"
|
data-slot="table-container"
|
||||||
className="relative w-full overflow-x-auto"
|
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
data-slot="table"
|
data-slot="table"
|
||||||
|
|||||||
@@ -1,20 +1,37 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Toaster, toast } from "sonner"
|
import { Toaster, toast } from "sonner"
|
||||||
import { fetchLineData, type LineItem, searchLineData } from "@/api/linedata"
|
import {
|
||||||
import { fetchProducts } from "@/api/product"
|
fetchLineData,
|
||||||
|
type LineItem,
|
||||||
|
PHP_API_BASE_URL,
|
||||||
|
searchLineData,
|
||||||
|
} from "@/api/linedata"
|
||||||
|
import DataTable from "@/components/data-table"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import Wrap from "@/components/wrap"
|
import Wrap from "@/components/wrap"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
const REFRESH_INTERVAL = 5 * 60 * 1000
|
const REFRESH_INTERVAL = 5 * 60 * 1000
|
||||||
|
const PAGE_SIZE_OPTIONS = [100, 200, 500]
|
||||||
|
|
||||||
type ProductOption = { id: number; name: string }
|
type ProductOption = {
|
||||||
|
id: number
|
||||||
type DisplayRow = {
|
name: string
|
||||||
item: LineItem
|
l2tp: string
|
||||||
nameSpan: number
|
sstp: string
|
||||||
citySpan: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRODUCTS: ProductOption[] = [
|
||||||
|
{ id: 29, name: "极狐IP", l2tp: "8899", sstp: "4430" },
|
||||||
|
{ id: 3, name: "极光IP", l2tp: "1234", sstp: "4430" },
|
||||||
|
// { id: 18, name: "蘑菇IP", l2tp: "8899", sstp: "4430" },
|
||||||
|
{ id: 27, name: "麒麟IP", l2tp: "123", sstp: "4432" },
|
||||||
|
{ id: 26, name: "猎豹IP", l2tp: "1234", sstp: "4430" },
|
||||||
|
{ id: 28, name: "水滴独享IP", l2tp: "123", sstp: "4432" },
|
||||||
|
{ id: 22, name: "火狐静态IP", l2tp: "888888", sstp: "5908" },
|
||||||
|
]
|
||||||
|
|
||||||
function toText(value: string | number): string {
|
function toText(value: string | number): string {
|
||||||
return value === null || value === undefined ? "" : String(value)
|
return value === null || value === undefined ? "" : String(value)
|
||||||
}
|
}
|
||||||
@@ -28,7 +45,6 @@ function onlineClass(value: string | number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function IpLinePage() {
|
export default function IpLinePage() {
|
||||||
const [products, setProducts] = useState<ProductOption[]>([])
|
|
||||||
const [productId, setProductId] = useState<number | null>(null)
|
const [productId, setProductId] = useState<number | null>(null)
|
||||||
const [rows, setRows] = useState<LineItem[]>([])
|
const [rows, setRows] = useState<LineItem[]>([])
|
||||||
const [count, setCount] = useState<number | string>(0)
|
const [count, setCount] = useState<number | string>(0)
|
||||||
@@ -37,34 +53,16 @@ export default function IpLinePage() {
|
|||||||
const [searching, setSearching] = useState(false)
|
const [searching, setSearching] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [pageIndex, setPageIndex] = useState(0)
|
||||||
useEffect(() => {
|
const [pageSize, setPageSize] = useState(100)
|
||||||
fetchProducts().then(result => {
|
const requestSeq = useRef(0)
|
||||||
if (!result.success) {
|
|
||||||
setError(result.message)
|
|
||||||
setLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const list = result.data
|
|
||||||
.filter(p => p.Product.OnLine === 1)
|
|
||||||
.sort((a, b) => a.Product.Sort - b.Product.Sort)
|
|
||||||
.map(p => ({ id: p.Product.Id, name: p.Product.Name }))
|
|
||||||
setProducts(list)
|
|
||||||
if (list[0]) {
|
|
||||||
setProductId(list[0].id)
|
|
||||||
} else {
|
|
||||||
setLoading(false)
|
|
||||||
setError(null)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadDisplay = useCallback(async (pid: number) => {
|
const loadDisplay = useCallback(async (pid: number) => {
|
||||||
|
const seq = ++requestSeq.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const result = await fetchLineData(pid)
|
const result = await fetchLineData(pid)
|
||||||
console.log(result, "resultresultresultresult")
|
if (seq !== requestSeq.current) return
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setRows(result.data.data ?? [])
|
setRows(result.data.data ?? [])
|
||||||
setCount(result.data.count ?? 0)
|
setCount(result.data.count ?? 0)
|
||||||
@@ -78,36 +76,53 @@ export default function IpLinePage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// 首次进入展示全部产品线路(product=0)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (productId !== null) {
|
loadDisplay(0)
|
||||||
setSearching(false)
|
}, [loadDisplay])
|
||||||
setKeyword("")
|
|
||||||
loadDisplay(productId)
|
|
||||||
}
|
|
||||||
}, [productId, loadDisplay])
|
|
||||||
|
|
||||||
|
// 每5分钟自动刷新(搜索状态除外)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (productId === null) return
|
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
if (!searching) loadDisplay(productId)
|
if (!searching) loadDisplay(productId ?? 0)
|
||||||
}, REFRESH_INTERVAL)
|
}, REFRESH_INTERVAL)
|
||||||
return () => clearInterval(timer)
|
return () => clearInterval(timer)
|
||||||
}, [productId, searching, loadDisplay])
|
}, [productId, searching, loadDisplay])
|
||||||
|
|
||||||
|
// 数据变化后修正越界页码
|
||||||
|
useEffect(() => {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize))
|
||||||
|
if (pageIndex >= totalPages) setPageIndex(totalPages - 1)
|
||||||
|
}, [rows.length, pageIndex, pageSize])
|
||||||
|
|
||||||
|
const handleSelectProduct = (id: number) => {
|
||||||
|
const next = productId === id ? null : id
|
||||||
|
setProductId(next)
|
||||||
|
setSearching(false)
|
||||||
|
setKeyword("")
|
||||||
|
setPageIndex(0)
|
||||||
|
loadDisplay(next ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = async () => {
|
const handleSearch = async () => {
|
||||||
if (productId === null) return
|
|
||||||
const info = keyword.trim()
|
const info = keyword.trim()
|
||||||
if (!info) {
|
if (!info) {
|
||||||
if (searching) {
|
if (searching) {
|
||||||
setSearching(false)
|
setSearching(false)
|
||||||
await loadDisplay(productId)
|
setPageIndex(0)
|
||||||
|
await loadDisplay(productId ?? 0)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSearching(true)
|
setSearching(true)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
const result = await searchLineData(productId, info)
|
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) {
|
if (result.success) {
|
||||||
setRows(result.data.data ?? [])
|
setRows(result.data.data ?? [])
|
||||||
} else {
|
} else {
|
||||||
@@ -117,16 +132,14 @@ export default function IpLinePage() {
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopy = async (text: string) => {
|
const handleCopy = useCallback(async (text: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
toast.success("复制成功")
|
toast.success("复制成功")
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("复制失败,请手动复制")
|
toast.error("复制失败,请手动复制")
|
||||||
}
|
}
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
const currentName = products.find(p => p.id === productId)?.name ?? ""
|
|
||||||
|
|
||||||
const handleExport = () => {
|
const handleExport = () => {
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -167,39 +180,20 @@ export default function IpLinePage() {
|
|||||||
toast.success("导出成功")
|
toast.success("导出成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayRows = useMemo<DisplayRow[]>(() => {
|
const currentProduct = PRODUCTS.find(p => p.id === productId)
|
||||||
const out: DisplayRow[] = []
|
const currentName = currentProduct?.name ?? "全部产品"
|
||||||
let i = 0
|
|
||||||
while (i < rows.length) {
|
const pageRows = useMemo(() => {
|
||||||
const item = rows[i]
|
const start = pageIndex * pageSize
|
||||||
let cityEnd = i + 1
|
return rows.slice(start, start + pageSize)
|
||||||
while (
|
}, [rows, pageIndex, pageSize])
|
||||||
cityEnd < rows.length &&
|
|
||||||
rows[cityEnd].name === item.name &&
|
const status = loading ? "load" : error ? "fail" : "done"
|
||||||
rows[cityEnd].city === item.city
|
|
||||||
) {
|
|
||||||
cityEnd += 1
|
|
||||||
}
|
|
||||||
let nameEnd = cityEnd
|
|
||||||
while (nameEnd < rows.length && rows[nameEnd].name === item.name) {
|
|
||||||
nameEnd += 1
|
|
||||||
}
|
|
||||||
for (let k = i; k < cityEnd; k += 1) {
|
|
||||||
out.push({
|
|
||||||
item: rows[k],
|
|
||||||
nameSpan: k === i ? nameEnd - i : 0,
|
|
||||||
citySpan: k === i ? cityEnd - i : 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
i = cityEnd
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}, [rows])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Toaster position="top-center" richColors />
|
<Toaster position="top-center" richColors />
|
||||||
<Wrap className="flex flex-col gap-4 mt-30 max-w-6xl mx-auto px-4 pb-20">
|
<Wrap className="flex flex-col gap-4 mt-30 max-w-6xl mx-auto px-4 pb-20 bg-white">
|
||||||
<div className="grid grid-cols-3">
|
<div className="grid grid-cols-3">
|
||||||
<div></div>
|
<div></div>
|
||||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||||
@@ -237,19 +231,20 @@ export default function IpLinePage() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
{products.map(p => (
|
{PRODUCTS.map(p => (
|
||||||
<button
|
<Button
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => setProductId(p.id)}
|
variant={p.id === productId ? "default" : "outline"}
|
||||||
|
onClick={() => handleSelectProduct(p.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-6 py-1.5 rounded-full text-sm font-medium transition-colors cursor-pointer",
|
"px-6 py-1.5 rounded-full",
|
||||||
p.id === productId
|
p.id === productId
|
||||||
? "text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200"
|
? "text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200 hover:opacity-90"
|
||||||
: "text-gray-600 border border-gray-200 hover:border-blue-400 bg-white",
|
: "border-gray-200 bg-white text-gray-600 hover:border-blue-400 hover:text-blue-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{p.name}
|
{p.name}
|
||||||
</button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -281,15 +276,16 @@ export default function IpLinePage() {
|
|||||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
|
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Button
|
||||||
onClick={handleSearch}
|
onClick={handleSearch}
|
||||||
className="px-8 py-2 bg-linear-to-r from-cyan-400 to-blue-500 text-white rounded-full text-sm font-medium shadow-md shadow-blue-200 cursor-pointer hover:opacity-90"
|
className="px-8 rounded-full bg-linear-to-r from-cyan-400 to-blue-500 text-white shadow-md shadow-blue-200 hover:opacity-90"
|
||||||
>
|
>
|
||||||
搜索当前线路表
|
搜索当前线路表
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
className="flex items-center gap-1.5 px-4 py-2 border border-blue-400 text-blue-500 rounded text-sm bg-white hover:bg-blue-50 transition-colors cursor-pointer"
|
className="gap-1.5 px-4 rounded border-blue-400 text-blue-500 bg-white hover:bg-blue-50 hover:text-blue-500"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
@@ -305,17 +301,21 @@ export default function IpLinePage() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
导出
|
导出
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
|
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
|
||||||
<div className="flex items-center gap-2 text-orange-500">
|
<div className="flex items-center gap-2 text-orange-500">
|
||||||
<span>L2TP密钥:</span>
|
<span>L2TP密钥:</span>
|
||||||
<span className="text-orange-600">1234</span>
|
<span className="text-orange-600">
|
||||||
|
{currentProduct?.l2tp || ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-orange-500">
|
<div className="flex items-center gap-2 text-orange-500">
|
||||||
<span>STTP端口:</span>
|
<span>STTP端口:</span>
|
||||||
<span className="text-orange-600">4430</span>
|
<span className="text-orange-600">
|
||||||
|
{currentProduct?.sstp || ""}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,108 +348,106 @@ export default function IpLinePage() {
|
|||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||||
<span className="text-gray-400">
|
<span className="text-gray-400 break-all">
|
||||||
{currentName}- (每5分钟更新一次,禁止频繁访问!) :{" "}
|
{currentName}- (每5分钟更新一次,禁止频繁访问!) :
|
||||||
|
{PHP_API_BASE_URL}/script/linedata/display.php?product=
|
||||||
|
{productId ?? 0}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-gray-200 rounded overflow-hidden">
|
{error && (
|
||||||
<table className="w-full text-center text-sm">
|
<div className="flex items-center justify-center gap-3 bg-red-50 border border-red-100 text-red-500 rounded-lg py-2 px-4 text-sm">
|
||||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
|
<span>加载失败:{error}</span>
|
||||||
<tr>
|
<Button
|
||||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
variant="link"
|
||||||
产品
|
onClick={() => loadDisplay(productId ?? 0)}
|
||||||
</th>
|
className="text-blue-500 hover:underline"
|
||||||
<th className="py-3 px-4 border-r border-gray-200 w-32">
|
>
|
||||||
城市
|
重试
|
||||||
</th>
|
</Button>
|
||||||
<th className="py-3 px-4 border-r border-gray-200 w-48">
|
</div>
|
||||||
运营商
|
)}
|
||||||
</th>
|
|
||||||
<th className="py-3 px-4 border-r border-gray-200">
|
<DataTable<LineItem>
|
||||||
服务器域名
|
data={pageRows}
|
||||||
</th>
|
status={status}
|
||||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
columns={useMemo<ColumnDef<LineItem>[]>(
|
||||||
带宽
|
() => [
|
||||||
</th>
|
{
|
||||||
<th className="py-3 px-4 w-24">服务器状态</th>
|
accessorKey: "name",
|
||||||
</tr>
|
header: "产品",
|
||||||
</thead>
|
cell: ({ row }) => toText(row.original.name),
|
||||||
<tbody className="bg-white divide-y divide-gray-100">
|
},
|
||||||
{loading ? (
|
{
|
||||||
<tr>
|
accessorKey: "city",
|
||||||
<td colSpan={6} className="py-10 text-gray-400">
|
header: "城市",
|
||||||
加载中...
|
cell: ({ row }) => toText(row.original.city),
|
||||||
</td>
|
},
|
||||||
</tr>
|
{
|
||||||
) : error && rows.length === 0 ? (
|
accessorKey: "supply",
|
||||||
<tr>
|
header: "运营商",
|
||||||
<td colSpan={6} className="py-10 text-gray-400">
|
cell: ({ row }) => toText(row.original.supply),
|
||||||
加载失败:{error}
|
},
|
||||||
<button
|
{
|
||||||
onClick={() =>
|
accessorKey: "nasname",
|
||||||
productId !== null && loadDisplay(productId)
|
header: "服务器域名",
|
||||||
}
|
cell: ({ row }) => {
|
||||||
className="ml-3 text-blue-500 hover:underline cursor-pointer"
|
const text = toText(row.original.nasname)
|
||||||
>
|
if (!text) return null
|
||||||
重试
|
return (
|
||||||
</button>
|
<span>
|
||||||
</td>
|
{text}
|
||||||
</tr>
|
<Button
|
||||||
) : rows.length === 0 ? (
|
variant="ghost"
|
||||||
<tr>
|
onClick={() => handleCopy(text)}
|
||||||
<td colSpan={6} className="py-10 text-gray-400">
|
className="ml-2 h-auto px-1 py-0 text-green-500 hover:bg-transparent hover:text-green-700 cursor-pointer"
|
||||||
{searching ? "未找到匹配线路" : "暂无线路数据"}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
displayRows.map((row, index) => (
|
|
||||||
<tr key={`${row.item.name}-${row.item.nasname}-${index}`}>
|
|
||||||
{row.nameSpan > 0 && (
|
|
||||||
<td
|
|
||||||
rowSpan={row.nameSpan}
|
|
||||||
className="py-3 px-4 border-r border-gray-200 text-gray-500 align-top"
|
|
||||||
>
|
>
|
||||||
{toText(row.item.name) || "-"}
|
复制
|
||||||
</td>
|
</Button>
|
||||||
)}
|
</span>
|
||||||
{row.citySpan > 0 && (
|
)
|
||||||
<td
|
},
|
||||||
rowSpan={row.citySpan}
|
},
|
||||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium align-top"
|
{
|
||||||
>
|
accessorKey: "daikuan",
|
||||||
{toText(row.item.city) || "-"}
|
header: "带宽",
|
||||||
</td>
|
cell: ({ row }) => toText(row.original.daikuan),
|
||||||
)}
|
},
|
||||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
{
|
||||||
{toText(row.item.supply) || "-"}
|
accessorKey: "online",
|
||||||
</td>
|
header: "服务器状态",
|
||||||
<td className="py-3 px-4 border-r border-gray-200">
|
cell: ({ row }) => {
|
||||||
<span>{toText(row.item.nasname) || "-"}</span>
|
const text = toText(row.original.online)
|
||||||
{toText(row.item.nasname) && (
|
return (
|
||||||
<button
|
<span className={onlineClass(row.original.online)}>
|
||||||
onClick={() => handleCopy(toText(row.item.nasname))}
|
{text}
|
||||||
className="ml-2 text-green-500 cursor-pointer hover:text-green-700"
|
</span>
|
||||||
>
|
)
|
||||||
复制
|
},
|
||||||
</button>
|
},
|
||||||
)}
|
],
|
||||||
</td>
|
[handleCopy],
|
||||||
<td className="py-3 px-4 border-r border-gray-200">
|
)}
|
||||||
{toText(row.item.daikuan) || "-"}
|
stickyHeader
|
||||||
</td>
|
maxHeight="max-h-[640px]"
|
||||||
<td
|
classNames={{ headRow: "bg-gray-50 text-gray-600" }}
|
||||||
className={cn("py-3 px-4", onlineClass(row.item.online))}
|
pagination={
|
||||||
>
|
rows.length > 0
|
||||||
{toText(row.item.online) || "-"}
|
? {
|
||||||
</td>
|
page: pageIndex + 1,
|
||||||
</tr>
|
size: pageSize,
|
||||||
))
|
total: rows.length,
|
||||||
)}
|
sizeOptions: PAGE_SIZE_OPTIONS,
|
||||||
</tbody>
|
onPageChange: page => setPageIndex(page - 1),
|
||||||
</table>
|
onSizeChange: size => {
|
||||||
</div>
|
setPageSize(size)
|
||||||
|
setPageIndex(0)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Wrap>
|
</Wrap>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,38 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { EyeIcon, EyeOffIcon } from "lucide-react"
|
import { EyeIcon, EyeOffIcon } from "lucide-react"
|
||||||
import { useEffect, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { Link, useLocation, useNavigate } from "react-router-dom"
|
import { Link, useLocation, useNavigate } from "react-router-dom"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import { z } from "zod"
|
||||||
import { apiLogin, apiRegister } from "@/api/auth"
|
import { apiLogin, apiRegister } from "@/api/auth"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import bg from "./_assets/bg.webp"
|
import bg from "./_assets/bg.webp"
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
account: z.string().trim().min(1, "请输入账号"),
|
||||||
|
password: z.string().min(1, "请输入密码"),
|
||||||
|
remember: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
type LoginValues = z.infer<typeof loginSchema>
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
phone: z.string().regex(/^1\d{10}$/, "请输入正确的手机号"),
|
||||||
|
smsCode: z.string().min(3, "请输入短信验证码").max(6, "请输入短信验证码"),
|
||||||
|
password: z.string().trim().min(1, "请输入密码"),
|
||||||
|
wx: z.string(),
|
||||||
|
qq: z.string(),
|
||||||
|
agreed: z.boolean().refine(v => v, "请先阅读并同意用户协议和隐私政策"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type RegisterValues = z.infer<typeof registerSchema>
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const [tab, setTab] = useState<"login" | "register">("login")
|
const [tab, setTab] = useState<"login" | "register">("login")
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -69,29 +92,25 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
function LoginForm() {
|
function LoginForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [account, setAccount] = useState("")
|
|
||||||
const [password, setPassword] = useState("")
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [remember, setRemember] = useState(false)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const form = useForm<LoginValues>({
|
||||||
e.preventDefault()
|
resolver: zodResolver(loginSchema),
|
||||||
if (!account.trim()) {
|
defaultValues: {
|
||||||
toast.error("请输入账号")
|
account: "",
|
||||||
return
|
password: "",
|
||||||
}
|
remember: false,
|
||||||
if (!password) {
|
},
|
||||||
toast.error("请输入密码")
|
})
|
||||||
return
|
|
||||||
}
|
const onSubmit = async (data: LoginValues) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const resp = await apiLogin({
|
const resp = await apiLogin({
|
||||||
logincode: account,
|
logincode: data.account,
|
||||||
password,
|
password: data.password,
|
||||||
})
|
})
|
||||||
console.log(resp, "resp")
|
|
||||||
|
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
toast.success("登录成功", {
|
toast.success("登录成功", {
|
||||||
@@ -109,53 +128,75 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="account" className="text-sm font-medium text-gray-700">
|
<label htmlFor="account" className="text-sm font-medium text-gray-700">
|
||||||
会员号
|
会员号
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Controller
|
||||||
id="account"
|
name="account"
|
||||||
type="text"
|
control={form.control}
|
||||||
placeholder="请输入会员号"
|
render={({ field, fieldState }) => (
|
||||||
className="w-full"
|
<>
|
||||||
value={account}
|
<Input
|
||||||
onChange={e => setAccount(e.target.value)}
|
id="account"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入会员号"
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="password" className="text-sm font-medium text-gray-700">
|
<label htmlFor="password" className="text-sm font-medium text-gray-700">
|
||||||
密码
|
密码
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<Controller
|
||||||
<Input
|
name="password"
|
||||||
id="password"
|
control={form.control}
|
||||||
type={showPassword ? "text" : "password"}
|
render={({ field, fieldState }) => (
|
||||||
placeholder="请输入密码"
|
<>
|
||||||
className="w-full pr-10"
|
<div className="relative">
|
||||||
value={password}
|
<Input
|
||||||
onChange={e => setPassword(e.target.value)}
|
id="password"
|
||||||
/>
|
type={showPassword ? "text" : "password"}
|
||||||
<button
|
placeholder="请输入密码"
|
||||||
type="button"
|
className="w-full pr-10"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
{...field}
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
/>
|
||||||
tabIndex={-1}
|
<button
|
||||||
>
|
type="button"
|
||||||
{showPassword ? (
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
<EyeOffIcon className="size-4" />
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
) : (
|
tabIndex={-1}
|
||||||
<EyeIcon className="size-4" />
|
>
|
||||||
)}
|
{showPassword ? (
|
||||||
</button>
|
<EyeOffIcon className="size-4" />
|
||||||
</div>
|
) : (
|
||||||
|
<EyeIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Controller
|
||||||
id="remember"
|
name="remember"
|
||||||
checked={remember}
|
control={form.control}
|
||||||
onCheckedChange={v => setRemember(!!v)}
|
render={({ field }) => (
|
||||||
|
<Checkbox
|
||||||
|
id="remember"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor="remember" className="text-gray-600 cursor-pointer">
|
<label htmlFor="remember" className="text-gray-600 cursor-pointer">
|
||||||
记住密码
|
记住密码
|
||||||
@@ -184,27 +225,31 @@ function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
||||||
const [phone, setPhone] = useState("")
|
|
||||||
const [smsCode, setSmsCode] = useState("")
|
|
||||||
const [smsCountdown, setSmsCountdown] = useState(0)
|
const [smsCountdown, setSmsCountdown] = useState(0)
|
||||||
const [smsLoading, setSmsLoading] = useState(false)
|
const [smsLoading, setSmsLoading] = useState(false)
|
||||||
const [newPassword, setNewPassword] = useState("")
|
|
||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [wx, setWx] = useState("")
|
|
||||||
const [qq, setQq] = useState("")
|
|
||||||
const [agreed, setAgreed] = useState(false)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
|
||||||
|
const form = useForm<RegisterValues>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
phone: "",
|
||||||
|
smsCode: "",
|
||||||
|
password: "",
|
||||||
|
wx: "",
|
||||||
|
qq: "",
|
||||||
|
agreed: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleSendSMS = async () => {
|
const handleSendSMS = async () => {
|
||||||
if (!/^1\d{10}$/.test(phone)) {
|
const valid = await form.trigger("phone")
|
||||||
toast.error("请输入正确的手机号")
|
if (!valid) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSmsLoading(true)
|
setSmsLoading(true)
|
||||||
try {
|
try {
|
||||||
const { sendSMS } = await import("@/api/verify")
|
const { sendSMS } = await import("@/api/verify")
|
||||||
const resp = await sendSMS(phone, "User_Code")
|
const resp = await sendSMS(form.getValues("phone"), "User_Code")
|
||||||
if (!resp.success) {
|
if (!resp.success) {
|
||||||
throw new Error(resp.message || "短信发送失败")
|
throw new Error(resp.message || "短信发送失败")
|
||||||
}
|
}
|
||||||
@@ -231,34 +276,15 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const onSubmit = async (data: RegisterValues) => {
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (!/^1\d{10}$/.test(phone)) {
|
|
||||||
toast.error("请输入正确的手机号")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (smsCode.length < 3 || smsCode.length > 6) {
|
|
||||||
toast.error("请输入短信验证码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!newPassword.trim()) {
|
|
||||||
toast.error("请输入密码")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!agreed) {
|
|
||||||
toast.error("请先阅读并同意用户协议和隐私政策")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const resp = await apiRegister({
|
const resp = await apiRegister({
|
||||||
phone,
|
phone: data.phone,
|
||||||
password: newPassword,
|
password: data.password,
|
||||||
code: smsCode,
|
code: data.smsCode,
|
||||||
wx,
|
wx: data.wx,
|
||||||
qq,
|
qq: data.qq,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
@@ -275,7 +301,7 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
<form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label
|
<label
|
||||||
htmlFor="reg-account"
|
htmlFor="reg-account"
|
||||||
@@ -283,29 +309,48 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
>
|
>
|
||||||
手机号
|
手机号
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Controller
|
||||||
id="reg-account"
|
name="phone"
|
||||||
type="text"
|
control={form.control}
|
||||||
placeholder="请输入手机号码"
|
render={({ field, fieldState }) => (
|
||||||
className="w-full"
|
<>
|
||||||
value={phone}
|
<Input
|
||||||
onChange={e => setPhone(e.target.value)}
|
id="reg-account"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入手机号码"
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="reg-sms" className="text-sm font-medium text-gray-700">
|
<label htmlFor="reg-sms" className="text-sm font-medium text-gray-700">
|
||||||
短信验证码
|
短信验证码
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Input
|
<Controller
|
||||||
id="reg-sms"
|
name="smsCode"
|
||||||
type="text"
|
control={form.control}
|
||||||
placeholder="请输入短信验证码"
|
render={({ field, fieldState }) => (
|
||||||
className="h-10"
|
<div className="flex-1 space-y-1">
|
||||||
maxLength={6}
|
<Input
|
||||||
autoComplete="one-time-code"
|
id="reg-sms"
|
||||||
value={smsCode}
|
type="text"
|
||||||
onChange={e => setSmsCode(e.target.value.replace(/\D/g, ""))}
|
placeholder="请输入短信验证码"
|
||||||
|
className="h-10"
|
||||||
|
maxLength={6}
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
{...field}
|
||||||
|
onChange={e =>
|
||||||
|
field.onChange(e.target.value.replace(/\D/g, ""))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -329,41 +374,54 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
>
|
>
|
||||||
请输入密码
|
请输入密码
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<Controller
|
||||||
<Input
|
name="password"
|
||||||
id="reg-password"
|
control={form.control}
|
||||||
type={showPassword ? "text" : "password"}
|
render={({ field, fieldState }) => (
|
||||||
placeholder="请输入密码"
|
<>
|
||||||
className="w-full pr-10"
|
<div className="relative">
|
||||||
value={newPassword}
|
<Input
|
||||||
onChange={e => setNewPassword(e.target.value)}
|
id="reg-password"
|
||||||
/>
|
type={showPassword ? "text" : "password"}
|
||||||
<button
|
placeholder="请输入密码"
|
||||||
type="button"
|
className="w-full pr-10"
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
{...field}
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
/>
|
||||||
tabIndex={-1}
|
<button
|
||||||
>
|
type="button"
|
||||||
{showPassword ? (
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
<EyeIcon className="size-4" />
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
) : (
|
tabIndex={-1}
|
||||||
<EyeOffIcon className="size-4" />
|
>
|
||||||
)}
|
{showPassword ? (
|
||||||
</button>
|
<EyeIcon className="size-4" />
|
||||||
</div>
|
) : (
|
||||||
|
<EyeOffIcon className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="reg-wx" className="text-sm font-medium text-gray-700">
|
<label htmlFor="reg-wx" className="text-sm font-medium text-gray-700">
|
||||||
微信号
|
微信号
|
||||||
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
|
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Controller
|
||||||
id="reg-wx"
|
name="wx"
|
||||||
type="text"
|
control={form.control}
|
||||||
placeholder="请输入微信号"
|
render={({ field }) => (
|
||||||
className="w-full"
|
<Input
|
||||||
value={wx}
|
id="reg-wx"
|
||||||
onChange={e => setWx(e.target.value)}
|
type="text"
|
||||||
|
placeholder="请输入微信号"
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -371,35 +429,52 @@ function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|||||||
QQ号
|
QQ号
|
||||||
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
|
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Controller
|
||||||
id="reg-qq"
|
name="qq"
|
||||||
type="text"
|
control={form.control}
|
||||||
placeholder="请输入QQ号"
|
render={({ field }) => (
|
||||||
className="w-full"
|
<Input
|
||||||
value={qq}
|
id="reg-qq"
|
||||||
onChange={e => setQq(e.target.value.replace(/\D/g, ""))}
|
type="text"
|
||||||
|
placeholder="请输入QQ号"
|
||||||
|
className="w-full"
|
||||||
|
{...field}
|
||||||
|
onChange={e => field.onChange(e.target.value.replace(/\D/g, ""))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="space-y-1">
|
||||||
<Checkbox
|
<Controller
|
||||||
id="agreement"
|
name="agreed"
|
||||||
checked={agreed}
|
control={form.control}
|
||||||
onCheckedChange={v => setAgreed(!!v)}
|
render={({ field, fieldState }) => (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="agreement"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={field.onChange}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="agreement"
|
||||||
|
className="text-xs text-gray-600 cursor-pointer"
|
||||||
|
>
|
||||||
|
我同意
|
||||||
|
<a
|
||||||
|
href="/xieyi.html"
|
||||||
|
className="text-blue-600 hover:text-blue-500"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
《聚IP JUIP.COM用户注册协议》
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<label
|
|
||||||
htmlFor="agreement"
|
|
||||||
className="text-xs text-gray-600 cursor-pointer"
|
|
||||||
>
|
|
||||||
我同意
|
|
||||||
<a
|
|
||||||
href="/xieyi.html"
|
|
||||||
className="text-blue-600 hover:text-blue-500"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
《聚IP JUIP.COM用户注册协议》
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useRef, useState } from "react"
|
||||||
import { useSearchParams } from "react-router-dom"
|
import { useSearchParams } from "react-router-dom"
|
||||||
import { fetchProducts } from "@/api/product"
|
import {
|
||||||
|
fetchProducts,
|
||||||
|
fetchStaticProducts,
|
||||||
|
fetchWindowProducts,
|
||||||
|
} from "@/api/product"
|
||||||
import Wrap from "@/components/wrap"
|
import Wrap from "@/components/wrap"
|
||||||
import type { Tab } from "@/lib/models/product"
|
import type { ApiResponse } from "@/lib/api"
|
||||||
|
import type { ProductItem, Tab, TabSource } from "@/lib/models/product"
|
||||||
import { transformToTabs } from "@/lib/models/product"
|
import { transformToTabs } from "@/lib/models/product"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import ProductGrid from "./productGrid"
|
import ProductGrid from "./productGrid"
|
||||||
@@ -13,11 +18,20 @@ import PurchaseInfo from "./purchaseInfo"
|
|||||||
const TAB_ALIAS: Record<string, string> = {
|
const TAB_ALIAS: Record<string, string> = {
|
||||||
short: "dynamic",
|
short: "dynamic",
|
||||||
long: "dynamic",
|
long: "dynamic",
|
||||||
http: "http",
|
|
||||||
tunnel: "dynamic",
|
tunnel: "dynamic",
|
||||||
exclusive: "static",
|
exclusive: "static",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAB_SOURCES: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
fetch: () => Promise<ApiResponse<ProductItem[]>>
|
||||||
|
}[] = [
|
||||||
|
{ id: "dynamic", label: "动态独享IP", fetch: fetchProducts },
|
||||||
|
{ id: "static", label: "静态IP", fetch: fetchStaticProducts },
|
||||||
|
{ id: "window", label: "单窗口单IP", fetch: fetchWindowProducts },
|
||||||
|
]
|
||||||
|
|
||||||
export default function ProductPage() {
|
export default function ProductPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [tabs, setTabs] = useState<Tab[]>([])
|
const [tabs, setTabs] = useState<Tab[]>([])
|
||||||
@@ -29,19 +43,56 @@ export default function ProductPage() {
|
|||||||
const initializedRef = useRef(false)
|
const initializedRef = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProducts().then(result => {
|
let cancelled = false
|
||||||
if (result.success) {
|
|
||||||
const newTabs = transformToTabs(result.data)
|
const load = async () => {
|
||||||
setTabs(newTabs)
|
const results = await Promise.allSettled(
|
||||||
if (!initializedRef.current && newTabs[0]?.brands[0]) {
|
TAB_SOURCES.map(source => source.fetch()),
|
||||||
setActiveBrandId(newTabs[0].brands[0].id)
|
)
|
||||||
initializedRef.current = true
|
console.log(results, "resultsresultsresults")
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
const sources: TabSource[] = []
|
||||||
|
const failedLabels: string[] = []
|
||||||
|
|
||||||
|
TAB_SOURCES.forEach((source, index) => {
|
||||||
|
const result = results[index]
|
||||||
|
if (result?.status === "fulfilled" && result.value.success) {
|
||||||
|
sources.push({
|
||||||
|
id: source.id,
|
||||||
|
label: source.label,
|
||||||
|
data: result.value.data,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
failedLabels.push(source.label)
|
||||||
}
|
}
|
||||||
} else {
|
})
|
||||||
setError(result.message)
|
|
||||||
|
if (sources.length === 0) {
|
||||||
|
setError("产品列表加载失败,请稍后重试")
|
||||||
|
setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failedLabels.length > 0) {
|
||||||
|
console.warn(`部分产品列表加载失败: ${failedLabels.join("、")}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTabs = transformToTabs(sources)
|
||||||
|
setTabs(newTabs)
|
||||||
|
if (!initializedRef.current && newTabs[0]?.brands[0]) {
|
||||||
|
setActiveBrandId(newTabs[0].brands[0].id)
|
||||||
|
initializedRef.current = true
|
||||||
}
|
}
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
load()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const urlTab = searchParams.get("tab") || "dynamic"
|
const urlTab = searchParams.get("tab") || "dynamic"
|
||||||
|
|||||||
@@ -15,13 +15,13 @@ export default function ProductTabs({
|
|||||||
}: ProductTabsProps) {
|
}: ProductTabsProps) {
|
||||||
return (
|
return (
|
||||||
<Tabs value={activeId} onValueChange={onTabChange} className="w-full">
|
<Tabs value={activeId} onValueChange={onTabChange} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-3 bg-slate-100 rounded-xl h-14">
|
<TabsList className="grid w-full h-auto bg-transparent p-0 grid-cols-3">
|
||||||
{tabs.map(tab => (
|
{tabs.map(tab => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={tab.id}
|
key={tab.id}
|
||||||
value={tab.id}
|
value={tab.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center outline-none border-0 ring-0",
|
"w-full h-auto py-2 rounded-full text-sm font-medium transition-all cursor-pointer text-center outline-none border-0 ring-0",
|
||||||
activeId === tab.id
|
activeId === tab.id
|
||||||
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white! shadow-none"
|
? "bg-linear-to-r from-blue-500 to-cyan-400 text-white! shadow-none"
|
||||||
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { useState } from "react"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { useEffect, useState } from "react"
|
||||||
|
import { Controller, useForm } from "react-hook-form"
|
||||||
|
import { z } from "zod"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
import { Card, CardContent } from "@/components/ui/card"
|
||||||
@@ -9,6 +12,7 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
|
import { FieldError } from "@/components/ui/field"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
@@ -18,6 +22,14 @@ import alipay from "../_assets/alipay.svg"
|
|||||||
import balance from "../_assets/balance.svg"
|
import balance from "../_assets/balance.svg"
|
||||||
import wechat from "../_assets/wechat.svg"
|
import wechat from "../_assets/wechat.svg"
|
||||||
|
|
||||||
|
const shippingSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, "请输入收货人"),
|
||||||
|
phone: z.string().regex(/^1\d{10}$/, "请输入正确的手机号"),
|
||||||
|
address: z.string().trim().min(1, "请输入地址"),
|
||||||
|
})
|
||||||
|
|
||||||
|
type ShippingValues = z.infer<typeof shippingSchema>
|
||||||
|
|
||||||
const PRODUCT_VARIANTS = [
|
const PRODUCT_VARIANTS = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function SoftDownloadPage() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrap className="flex flex-col gap-6 mt-30">
|
<Wrap className="flex flex-col gap-6 mt-30 bg-white">
|
||||||
<div className="grid grid-cols-3">
|
<div className="grid grid-cols-3">
|
||||||
<div></div>
|
<div></div>
|
||||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||||
|
|||||||
67
src/lib/models/order.ts
Normal file
67
src/lib/models/order.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export type OrderItem = {
|
||||||
|
Id: number
|
||||||
|
OrderNo: string
|
||||||
|
OrderType: number
|
||||||
|
ProductId: number
|
||||||
|
ProductName: string
|
||||||
|
PackageName: string
|
||||||
|
DayPrice: number
|
||||||
|
ConnectCount: number
|
||||||
|
AccountCount: number
|
||||||
|
Accounts: string
|
||||||
|
OrderAmount: number
|
||||||
|
CouponAmount: number
|
||||||
|
PaymentAmount: number
|
||||||
|
CreateTime: string
|
||||||
|
UpdateTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OrderFilters = {
|
||||||
|
orderType: string
|
||||||
|
productId: string
|
||||||
|
packageName: string
|
||||||
|
keyword: string
|
||||||
|
bTime: string
|
||||||
|
eTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_FILTERS: OrderFilters = {
|
||||||
|
orderType: "0",
|
||||||
|
productId: "0",
|
||||||
|
packageName: "0",
|
||||||
|
keyword: "",
|
||||||
|
bTime: "",
|
||||||
|
eTime: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ORDER_TYPE_LABELS: Record<number, string> = {
|
||||||
|
1: "新开",
|
||||||
|
2: "续费",
|
||||||
|
3: "批量新开",
|
||||||
|
4: "批量续费",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PACKAGE_OPTIONS = [
|
||||||
|
"测试卡",
|
||||||
|
"天卡",
|
||||||
|
"周卡",
|
||||||
|
"月卡",
|
||||||
|
"月卡(活动)",
|
||||||
|
"双月卡(活动)",
|
||||||
|
"季卡",
|
||||||
|
"季卡(活动)",
|
||||||
|
"年卡",
|
||||||
|
]
|
||||||
|
|
||||||
|
export function formatOrderType(type: number): string {
|
||||||
|
return ORDER_TYPE_LABELS[type] ?? String(type)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cutAccount(accounts: string, max = 15): string {
|
||||||
|
if (accounts.length > max) return `${accounts.slice(0, max)}...`
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totalConnections(item: OrderItem): number {
|
||||||
|
return item.ConnectCount * item.AccountCount
|
||||||
|
}
|
||||||
@@ -31,6 +31,12 @@ export type Tab = {
|
|||||||
brands: Brand[]
|
brands: Brand[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TabSource = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
data: ProductItem[]
|
||||||
|
}
|
||||||
|
|
||||||
export type ProductItem = {
|
export type ProductItem = {
|
||||||
Product: {
|
Product: {
|
||||||
Id: number
|
Id: number
|
||||||
@@ -72,16 +78,12 @@ function extractTier(name: string): string {
|
|||||||
return match ? match[1] : "默认"
|
return match ? match[1] : "默认"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformToTabs(data: ProductItem[]): Tab[] {
|
function transformToBrands(data: ProductItem[]): Brand[] {
|
||||||
const activeProducts = data.filter(p => p.Product.OnLine === 1)
|
const activeProducts = data.filter(p => p.Product.OnLine === 1)
|
||||||
|
|
||||||
activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort)
|
activeProducts.sort((a, b) => a.Product.Sort - b.Product.Sort)
|
||||||
console.log(
|
|
||||||
activeProducts,
|
|
||||||
"activeProductsactiveProductsactiveProductsactiveProducts",
|
|
||||||
)
|
|
||||||
|
|
||||||
const brands: Brand[] = activeProducts.map(item => {
|
return activeProducts.map(item => {
|
||||||
const activePackages = item.Packages.filter(pkg => pkg.Status === 1)
|
const activePackages = item.Packages.filter(pkg => pkg.Status === 1)
|
||||||
|
|
||||||
const tierMap = new Map<string, PackageItem[]>()
|
const tierMap = new Map<string, PackageItem[]>()
|
||||||
@@ -122,18 +124,24 @@ export function transformToTabs(data: ProductItem[]): Tab[] {
|
|||||||
products,
|
products,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
}
|
||||||
brands.push({
|
|
||||||
id: "info",
|
export function transformToTabs(sources: TabSource[]): Tab[] {
|
||||||
name: "购买须知",
|
const tabs: Tab[] = []
|
||||||
products: [],
|
for (const source of sources) {
|
||||||
})
|
const brands = transformToBrands(source.data)
|
||||||
|
if (brands.length === 0) continue
|
||||||
return [
|
tabs.push({ id: source.id, label: source.label, brands })
|
||||||
{
|
}
|
||||||
id: "dynamic",
|
|
||||||
label: "动态独享IP",
|
const firstTab = tabs[0]
|
||||||
brands,
|
if (firstTab) {
|
||||||
},
|
firstTab.brands.push({
|
||||||
]
|
id: "info",
|
||||||
|
name: "购买须知",
|
||||||
|
products: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return tabs
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/lib/models/record.ts
Normal file
30
src/lib/models/record.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export type HttpUsedSummary = {
|
||||||
|
todayShortPackIpUsed: number
|
||||||
|
shortPackIpUsed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpUsedLogItem = {
|
||||||
|
packType: number
|
||||||
|
userIp: string
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
createTime: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HttpUsedLogQuery = {
|
||||||
|
page: number
|
||||||
|
limit: number
|
||||||
|
packType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HTTP_USE_PACK_TYPE_LABELS: Record<number, string> = {
|
||||||
|
12: "储值套餐",
|
||||||
|
21: "短效包天套餐",
|
||||||
|
22: "短效包量套餐",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HTTP_USE_PACK_TYPE_OPTIONS = [21, 22]
|
||||||
|
|
||||||
|
export function formatPackType(packType: number): string {
|
||||||
|
return HTTP_USE_PACK_TYPE_LABELS[packType] ?? String(packType)
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
import { createBrowserRouter } from "react-router-dom"
|
import { createBrowserRouter } from "react-router-dom"
|
||||||
import Channels from "@/admin/channels/page"
|
import Channels from "@/admin/channels/page"
|
||||||
|
import CouponPage from "@/admin/coupon/page"
|
||||||
import Dashboard from "@/admin/dashboard/page"
|
import Dashboard from "@/admin/dashboard/page"
|
||||||
import Funds from "@/admin/funds/page"
|
import Funds from "@/admin/funds/page"
|
||||||
|
import HttpRechargePage from "@/admin/httpRecharge/page"
|
||||||
import AdminLayout from "@/admin/layout"
|
import AdminLayout from "@/admin/layout"
|
||||||
import LongPage from "@/admin/product/long"
|
import OrdersPage from "@/admin/orders/page"
|
||||||
import ShortPage from "@/admin/product/short"
|
import RecordPage from "@/admin/record/page"
|
||||||
|
import RefundOrdersPage from "@/admin/refundOrders/page"
|
||||||
|
import LongPage from "@/admin/resources/long"
|
||||||
|
import ShortPage from "@/admin/resources/short"
|
||||||
|
import RosorderPage from "@/admin/rosorder/page"
|
||||||
import Whitelist from "@/admin/whitelist/page"
|
import Whitelist from "@/admin/whitelist/page"
|
||||||
import AuthGuard from "@/components/authGuard"
|
import AuthGuard from "@/components/authGuard"
|
||||||
import HomePage from "@/home"
|
import HomePage from "@/home"
|
||||||
@@ -57,6 +63,14 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "short", element: <ShortPage /> },
|
{ path: "short", element: <ShortPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{ path: "orders", element: <OrdersPage /> },
|
||||||
|
{ path: "refundOrders", element: <RefundOrdersPage /> },
|
||||||
|
{ path: "httpRecharge", element: <HttpRechargePage /> },
|
||||||
|
{ path: "record", element: <RecordPage /> },
|
||||||
|
{ path: "coupon", element: <CouponPage /> },
|
||||||
|
{ path: "resources/long", element: <LongPage /> },
|
||||||
|
{ path: "resources/short", element: <ShortPage /> },
|
||||||
|
{ path: "rosorder", element: <RosorderPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/script": "http://192.168.3.6:5000",
|
"/script": "https://php-api.juip.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user