Compare commits
5 Commits
6151add53f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c295f15724 | ||
|
|
65e1eca221 | ||
|
|
b5076d7ca7 | ||
|
|
1fc625ede8 | ||
|
|
160a9c9b97 |
@@ -116,6 +116,11 @@ export default function ChannelsPage() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [selection, setSelection] = useState<Record<string, boolean>>({})
|
||||
|
||||
const selectedAccounts = useMemo(
|
||||
() => data.filter(item => selection[String(item.Id)]),
|
||||
[data, selection],
|
||||
)
|
||||
|
||||
const selectedProduct = useMemo(
|
||||
() =>
|
||||
products.find(item => String(item.Product.Id) === query.productId) ??
|
||||
@@ -207,7 +212,10 @@ export default function ChannelsPage() {
|
||||
<SelectContent>
|
||||
<SelectItem value="0">全部产品</SelectItem>
|
||||
{products.map(item => (
|
||||
<SelectItem key={item.Product.Id} value={String(item.Product.Id)}>
|
||||
<SelectItem
|
||||
key={item.Product.Id}
|
||||
value={String(item.Product.Id)}
|
||||
>
|
||||
{item.Product.Name}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -256,7 +264,25 @@ export default function ChannelsPage() {
|
||||
<div className="flex flex-wrap items-center gap-4 pt-2 p-4">
|
||||
<Button
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => toast.info("续费功能开发中,敬请期待")}
|
||||
onClick={() => {
|
||||
if (selectedAccounts.length === 0) {
|
||||
toast.error("请先勾选需要续费的账号")
|
||||
return
|
||||
}
|
||||
const productIds = new Set(
|
||||
selectedAccounts.map(item => item.ProductId),
|
||||
)
|
||||
if (productIds.size > 1) {
|
||||
toast.error("所选账号必须为同一种产品")
|
||||
return
|
||||
}
|
||||
const accounts = selectedAccounts
|
||||
.map(item => item.Account)
|
||||
.join(",")
|
||||
const productId = selectedAccounts[0].ProductId
|
||||
// 续费走后端老页面(/product/rebuyindex → /product/rebuy),与 juipnet 行为一致
|
||||
window.location.href = `/product/rebuyindex?accounts=${encodeURIComponent(accounts)}&productId=${productId}`
|
||||
}}
|
||||
>
|
||||
续费
|
||||
</Button>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { MyAccountItem, MyAccountQueryParams } from "@/lib/models/account"
|
||||
import { callPublic } from "./base"
|
||||
import { callByUserCookie } from "./base"
|
||||
|
||||
export async function fetchMyAccounts(
|
||||
params: MyAccountQueryParams,
|
||||
): Promise<ApiResponse<MyAccountItem[]>> {
|
||||
const res = await callPublic<MyAccountItem[] | unknown>(
|
||||
const res = await callByUserCookie<MyAccountItem[] | unknown>(
|
||||
"/user/ApiMyAccounts",
|
||||
params,
|
||||
"GET",
|
||||
|
||||
@@ -4,9 +4,27 @@ import {
|
||||
CLIENT_ID,
|
||||
CLIENT_SECRET,
|
||||
} from "@/lib/api"
|
||||
import { getUserToken } from "./token"
|
||||
import { clearCookie, clearSession, getUserToken } from "./token"
|
||||
|
||||
type CallMode = "public" | "device" | "user"
|
||||
type CallMode = "public" | "device" | "user" | "cookie"
|
||||
|
||||
// ==================== 登录态失效全局处理 ====================
|
||||
// user/cookie 模式请求未登录、收到 401(token 失效)或 302(后端跳登录页)时:
|
||||
// 清除本地会话并跳转登录页,带 redirect 参数便于登录后回跳。
|
||||
// 与 logout() 及 juipnet 老项目行为保持一致。
|
||||
let redirectingToLogin = false
|
||||
|
||||
function handleUnauthorized() {
|
||||
if (redirectingToLogin) return
|
||||
clearSession()
|
||||
clearCookie("token")
|
||||
clearCookie("userInfo")
|
||||
const current = window.location.pathname
|
||||
if (current !== "/login") {
|
||||
redirectingToLogin = true
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(current)}`
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT = 15_000
|
||||
|
||||
@@ -45,8 +63,20 @@ async function request<R = undefined>(
|
||||
headers,
|
||||
body: init.body,
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
})
|
||||
|
||||
// 登录态失效时后端会把受保护接口 302 到登录页(如 /User/WebLogin)。
|
||||
// manual 模式不自动跟随,此处识别重定向并返回 302,
|
||||
// 由 call() 在 user/cookie 模式下统一触发 handleUnauthorized 跳登录页。
|
||||
if (response.type === "opaqueredirect") {
|
||||
return {
|
||||
success: false,
|
||||
status: 302,
|
||||
message: "登录状态已失效,请重新登录",
|
||||
}
|
||||
}
|
||||
|
||||
const responseHeaders = captureHeaders(response)
|
||||
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
||||
|
||||
@@ -108,7 +138,13 @@ async function request<R = undefined>(
|
||||
return { success: true, data: json, headers: responseHeaders }
|
||||
}
|
||||
|
||||
throw new Error(`无法解析响应数据: ${contentType}`)
|
||||
// 其他内容类型(如 text/html 错误页)无法按 API 格式解析,
|
||||
// 保留真实 HTTP 状态码返回,便于上层识别 401/3xx 等登录态问题
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: `无法解析响应数据: ${contentType}`,
|
||||
}
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
@@ -210,6 +246,7 @@ async function call<R = undefined>(
|
||||
if (mode === "user") {
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
handleUnauthorized()
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
@@ -217,6 +254,18 @@ async function call<R = undefined>(
|
||||
}
|
||||
}
|
||||
auth = `Bearer ${token}`
|
||||
} else if (mode === "cookie") {
|
||||
// cookie 模式:凭证走浏览器 cookie(老 /user/* 接口),
|
||||
// 但登录态判断仍以本地 token 为准,缺失时同样触发跳登录
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
handleUnauthorized()
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "未登录或会话已过期",
|
||||
}
|
||||
}
|
||||
} else if (mode === "device") {
|
||||
if (attempt > 1) {
|
||||
deviceToken = null
|
||||
@@ -243,6 +292,16 @@ async function call<R = undefined>(
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 会话失效的三种表现:本地无 token(前面已处理)、后端 401、
|
||||
// 或后端 302 重定向到登录页(如 /user/* 过期跳 /User/WebLogin)
|
||||
if (
|
||||
!resp.success &&
|
||||
(resp.status === 401 || resp.status === 302) &&
|
||||
(mode === "user" || mode === "cookie")
|
||||
) {
|
||||
handleUnauthorized()
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
@@ -283,4 +342,12 @@ async function callByUser<R = undefined>(
|
||||
return call<R>("user", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callPublic }
|
||||
async function callByUserCookie<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
method: "GET" | "POST" = "POST",
|
||||
): Promise<ApiResponse<R>> {
|
||||
return call<R>("cookie", endpoint, data, method)
|
||||
}
|
||||
|
||||
export { callByDevice, callByUser, callByUserCookie, callPublic }
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type { ApiIndexItem } from "@/lib/models/dashboard"
|
||||
import { callPublic } from "./base"
|
||||
import { callByUserCookie } from "./base"
|
||||
|
||||
export async function fetchApiIndex(): Promise<
|
||||
ApiResponse<ApiIndexItem | null>
|
||||
> {
|
||||
const res = await callPublic<ApiIndexItem | ApiIndexItem[]>(
|
||||
const res = await callByUserCookie<ApiIndexItem | ApiIndexItem[]>(
|
||||
"/user/ApiIndex",
|
||||
undefined,
|
||||
"GET",
|
||||
|
||||
151
src/api/http.ts
151
src/api/http.ts
@@ -1,6 +1,9 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
CreateOrderResult,
|
||||
DxblData,
|
||||
DxbtData,
|
||||
DxwxlData,
|
||||
GameCity,
|
||||
GameItem,
|
||||
GameOrderData,
|
||||
@@ -30,28 +33,68 @@ const DEFAULT_TIMEOUT = 30_000
|
||||
|
||||
type RawHttpResponse = Record<string, unknown>
|
||||
|
||||
// ==================== 统一 PHP 请求底层 ====================
|
||||
// 所有 /http/* 接口都带 cookie 走同一出口:超时、错误归一化在此层处理,
|
||||
// 各接口函数只关心业务数据,不再各自实现 fetch。
|
||||
// credentials 仅对需要登录态的 POST 开启;公开脚本 GET 接口(如线路表)
|
||||
// 不带 cookie,避免跨域时服务端 Access-Control-Allow-Origin 配置不匹配。
|
||||
|
||||
type PhpFetchOptions = {
|
||||
credentials?: RequestCredentials
|
||||
cache?: RequestCache
|
||||
encoding?: "json" | "form"
|
||||
}
|
||||
|
||||
// 表单编码:嵌套对象/数组展开为 data[key] 结构(PHP $_POST 规则)
|
||||
function toFormParams(params: URLSearchParams, prefix: string, value: unknown) {
|
||||
if (value === null || value === undefined) return
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v, i) => {
|
||||
toFormParams(params, `${prefix}[${i}]`, v)
|
||||
})
|
||||
return
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
toFormParams(params, `${prefix}[${k}]`, v)
|
||||
}
|
||||
return
|
||||
}
|
||||
params.set(prefix, String(value))
|
||||
}
|
||||
|
||||
export async function phpFetch<T = RawHttpResponse>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options: { credentials?: RequestCredentials } = {},
|
||||
options: PhpFetchOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
const { credentials = "same-origin" } = options
|
||||
const { credentials = "same-origin", cache, encoding = "json" } = options
|
||||
const isForm = encoding === "form" && body !== undefined
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT)
|
||||
try {
|
||||
let payload: string | undefined
|
||||
if (body !== undefined) {
|
||||
if (isForm) {
|
||||
const params = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
|
||||
toFormParams(params, k, v)
|
||||
}
|
||||
payload = params.toString()
|
||||
} else {
|
||||
payload = JSON.stringify(body)
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${PHP_API_BASE_URL}${path}`, {
|
||||
method,
|
||||
headers:
|
||||
body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
headers: isForm
|
||||
? { "Content-Type": "application/x-www-form-urlencoded" }
|
||||
: body !== undefined
|
||||
? { "Content-Type": "application/json" }
|
||||
: undefined,
|
||||
credentials,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
...(cache ? { cache } : {}),
|
||||
body: payload,
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
@@ -86,8 +129,9 @@ export async function phpFetch<T = RawHttpResponse>(
|
||||
export async function phpPost<T = RawHttpResponse>(
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options: PhpFetchOptions = {},
|
||||
): Promise<ApiResponse<T>> {
|
||||
return phpFetch<T>("POST", path, body, { credentials: "include" })
|
||||
return phpFetch<T>("POST", path, body, { credentials: "include", ...options })
|
||||
}
|
||||
|
||||
// multipart 上传(手持身份证等文件场景),不设 Content-Type,由浏览器自动补全 boundary
|
||||
@@ -139,7 +183,7 @@ export async function phpGet<T = RawHttpResponse>(
|
||||
|
||||
// ==================== 响应解析工具 ====================
|
||||
|
||||
// 老 PHP 接口习惯把业务数据包在 d 字段里
|
||||
// 接口习惯把业务数据包在 d 字段里
|
||||
export function unwrapD(
|
||||
raw: RawHttpResponse | undefined,
|
||||
): Record<string, unknown> {
|
||||
@@ -176,7 +220,9 @@ export function parsePager(raw: unknown, itemCount: number): Pager {
|
||||
export async function fetchGameCities(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameCity[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/city", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/city", game, {
|
||||
encoding: "form",
|
||||
})
|
||||
if (!res.success) return res
|
||||
const cities = unwrapD(res.data).cities
|
||||
return {
|
||||
@@ -188,7 +234,9 @@ export async function fetchGameCities(
|
||||
export async function fetchGameList(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<GameItem[]>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/game", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/game", game, {
|
||||
encoding: "form",
|
||||
})
|
||||
if (!res.success) return res
|
||||
const games = unwrapD(res.data).games
|
||||
return {
|
||||
@@ -200,7 +248,9 @@ export async function fetchGameList(
|
||||
export async function fetchGameLineCount(
|
||||
game: GameOrderData,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/linecount", game)
|
||||
const res = await phpPost<RawHttpResponse>("/http/product/linecount", game, {
|
||||
encoding: "form",
|
||||
})
|
||||
if (!res.success) return res
|
||||
const count = unwrapD(res.data).count
|
||||
return { success: true, data: toNumber(count) }
|
||||
@@ -212,21 +262,86 @@ export async function calcGamePrice(
|
||||
return calcOrderPrice(orderInfo as unknown as PackageOrderInfo)
|
||||
}
|
||||
|
||||
export async function calcUnlimitedPrice(
|
||||
data: DxwxlData,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
{
|
||||
order_type: 2,
|
||||
money: 1,
|
||||
pay_type: 1,
|
||||
data,
|
||||
},
|
||||
{ encoding: "form" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const d = unwrapD(raw)
|
||||
return { success: true, data: toNumber(raw.price ?? d.price) }
|
||||
}
|
||||
|
||||
// calc_price 原始返回:每 ipAmount 个 IP 价格为 price
|
||||
export type CalcPriceResult = {
|
||||
ipAmount: number
|
||||
price: number
|
||||
}
|
||||
|
||||
async function calcPriceWithIp(
|
||||
orderType: number,
|
||||
data: unknown,
|
||||
): Promise<ApiResponse<CalcPriceResult>> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
{
|
||||
order_type: orderType,
|
||||
money: 1,
|
||||
pay_type: 1,
|
||||
data,
|
||||
},
|
||||
{ encoding: "form" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const d = unwrapD(raw)
|
||||
const rawIp = raw.ipAmount ?? d.ipAmount
|
||||
// ipAmount 为空时按 1 处理(每 1 个 IP 价格为 price)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
ipAmount: rawIp === null || rawIp === undefined ? 1 : toNumber(rawIp),
|
||||
price: toNumber(raw.price ?? d.price),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 短效包天:返回每 IP 每天单价(总价 = periodAmount * price * ipAmount)
|
||||
export async function calcDxbtPrice(
|
||||
data: DxbtData,
|
||||
): Promise<ApiResponse<CalcPriceResult>> {
|
||||
return calcPriceWithIp(3, data)
|
||||
}
|
||||
|
||||
// 短效包量:返回每 1 元可购 IP 数量(总 IP = money * ipAmount)
|
||||
export async function calcDxblPrice(
|
||||
data: DxblData,
|
||||
): Promise<ApiResponse<CalcPriceResult>> {
|
||||
return calcPriceWithIp(4, data)
|
||||
}
|
||||
|
||||
export async function calcOrderPrice(
|
||||
orderInfo: PackageOrderInfo,
|
||||
): Promise<ApiResponse<number>> {
|
||||
const res = await phpPost<RawHttpResponse>(
|
||||
"/http/product/calc_price",
|
||||
orderInfo,
|
||||
{ encoding: "form" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
const priceValue =
|
||||
typeof raw.price === "number" ? raw.price : unwrapD(raw).price
|
||||
return {
|
||||
success: true,
|
||||
data: typeof priceValue === "number" ? priceValue : 0,
|
||||
}
|
||||
const d = unwrapD(raw)
|
||||
// price 可能为字符串(如 "9.9000"),统一 toNumber 解析
|
||||
return { success: true, data: toNumber(raw.price ?? d.price) }
|
||||
}
|
||||
|
||||
// ==================== 订单 ====================
|
||||
@@ -370,7 +485,6 @@ export async function fetchCxOrders(): Promise<ApiResponse<CxOrder[]>> {
|
||||
export async function fetchLangIpList(
|
||||
query: Record<string, unknown>,
|
||||
): Promise<ApiResponse<LangIpListResult>> {
|
||||
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/lang_ip_list", {
|
||||
cookie: document.cookie,
|
||||
...query,
|
||||
@@ -387,7 +501,6 @@ export async function fetchLangIpList(
|
||||
export async function fetchSwitchList(
|
||||
query: Record<string, unknown>,
|
||||
): Promise<ApiResponse<SwitchListResult>> {
|
||||
|
||||
const res = await phpPost<RawHttpResponse>("/http/user/cx_switch", {
|
||||
cookie: document.cookie,
|
||||
...query,
|
||||
|
||||
113
src/api/shuidi.ts
Normal file
113
src/api/shuidi.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import type {
|
||||
ShuidiGame,
|
||||
ShuidiOrderInfo,
|
||||
ShuidiOrderResult,
|
||||
ShuidiPackageList,
|
||||
ShuidiRegion,
|
||||
} from "@/lib/models/shuidi"
|
||||
import { phpFetch, phpPost } from "./http"
|
||||
|
||||
// ==================== 水滴静态/固态(jinyou) ====================
|
||||
// - 游戏列表:POST /jinyou/api/gameList(不带 cookie)
|
||||
// - 套餐列表:POST /jinyou/Jinyoujt/getPackage?type=1(1:静态 2:固态),需带 cookie
|
||||
// - 线路列表:GET /jinyou/api/regionSurplusOuts?product_id=&is_unshar=&game_id=(不带 cookie)
|
||||
|
||||
type JinyouResult<T> = {
|
||||
code: number
|
||||
msg?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export async function fetchShuidiGameList(): Promise<
|
||||
ApiResponse<ShuidiGame[]>
|
||||
> {
|
||||
const res = await phpFetch<JinyouResult<ShuidiGame[]>>(
|
||||
"POST",
|
||||
"/jinyou/api/gameList",
|
||||
)
|
||||
if (!res.success) return res
|
||||
const data = res.data?.data
|
||||
return {
|
||||
success: true,
|
||||
data: Array.isArray(data) ? data : [],
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchShuidiPackages(
|
||||
type: 1 | 2 = 1,
|
||||
): Promise<ApiResponse<ShuidiPackageList>> {
|
||||
const res = await phpPost<JinyouResult<ShuidiPackageList>>(
|
||||
`/jinyou/Jinyoujt/getPackage?type=${type}`,
|
||||
{ cookie: document.cookie },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const data = res.data?.data
|
||||
if (!data || typeof data !== "object") {
|
||||
return { success: true, data: { game: {}, line: {} } }
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
game: data.game ?? {},
|
||||
line: data.line ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchShuidiRegions(params: {
|
||||
product_id: number
|
||||
is_unshar: 0 | 1
|
||||
game_id: number | ""
|
||||
}): Promise<ApiResponse<ShuidiRegion>> {
|
||||
const query = new URLSearchParams({
|
||||
product_id: String(params.product_id),
|
||||
is_unshar: String(params.is_unshar),
|
||||
game_id: String(params.game_id),
|
||||
})
|
||||
// 余量数据时效性强,禁止浏览器缓存
|
||||
const res = await phpFetch<JinyouResult<{ region?: ShuidiRegion }>>(
|
||||
"GET",
|
||||
`/jinyou/api/regionSurplusOuts?${query.toString()}`,
|
||||
undefined,
|
||||
{ cache: "no-store" },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const region = res.data?.data?.region
|
||||
if (!region || typeof region !== "object") {
|
||||
return {
|
||||
success: true,
|
||||
data: { province_surplus_outs: {} },
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
surplus_out_total: region.surplus_out_total,
|
||||
province_surplus_outs: region.province_surplus_outs ?? {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 下单 ====================
|
||||
// 与 juipnet Index.cshtml pay() 一致:body 为 { cookie, order_info }
|
||||
// code 1-开通成功 2-返回支付宝支付主体 3-返回微信支付主体 -1-错误
|
||||
|
||||
export async function createShuidiOrder(
|
||||
orderInfo: ShuidiOrderInfo,
|
||||
): Promise<ApiResponse<ShuidiOrderResult>> {
|
||||
const res = await phpPost<Record<string, unknown>>(
|
||||
"/jinyou/jinyoujt/create_order",
|
||||
{ cookie: document.cookie, order_info: orderInfo },
|
||||
)
|
||||
if (!res.success) return res
|
||||
const raw = res.data ?? {}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
code: Number(raw.code) || 0,
|
||||
msg: typeof raw.msg === "string" ? raw.msg : undefined,
|
||||
data: typeof raw.data === "string" ? raw.data : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
UserItem,
|
||||
UserQueryParams,
|
||||
} from "@/lib/models/user"
|
||||
import { callByUser, callPublic } from "./base"
|
||||
import { callByUser, callByUserCookie } from "./base"
|
||||
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
@@ -54,11 +54,11 @@ export async function updateUserProfile(
|
||||
export async function updateUserInfo(
|
||||
data: UpdateUserInfoParams,
|
||||
): Promise<ApiResponse<undefined>> {
|
||||
return await callPublic<undefined>("/user/ApiUpdateInfo", data)
|
||||
return await callByUserCookie<undefined>("/user/ApiUpdateInfo", data)
|
||||
}
|
||||
|
||||
export async function updateUserPwd(
|
||||
data: UpdateUserPwdParams,
|
||||
): Promise<ApiResponse<PhpUserModel>> {
|
||||
return await callPublic<PhpUserModel>("/user/UpdatePwd", data)
|
||||
return await callByUserCookie<PhpUserModel>("/user/UpdatePwd", data)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { useProfileStore } from "@/components/stores/profile"
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const isValid = useProfileStore(state => state.token !== null)
|
||||
const token = useProfileStore(state => state.token)
|
||||
const userInfo = useProfileStore(state => state.userInfo)
|
||||
|
||||
const isValid = token !== null && userInfo !== null
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { useLocation, useNavigate } from "react-router-dom"
|
||||
import { toast } from "sonner"
|
||||
import { createOrder } from "@/api/order"
|
||||
import { createOrder, createTestAccount } from "@/api/order"
|
||||
import RealNameCheck from "@/components/realNameCheck"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
@@ -18,7 +18,6 @@ import Wrap from "@/components/wrap"
|
||||
import type { CreateOrderRequest } from "@/lib/models/order"
|
||||
import type { Product } from "@/lib/models/product"
|
||||
import { clearPayPaid, PAY_PAID_KEY, saveOrderPay } from "@/lib/recharge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import alipay from "../_assets/alipay.svg"
|
||||
import balance from "../_assets/balance.svg"
|
||||
import wechat from "../_assets/wechat.svg"
|
||||
@@ -41,42 +40,6 @@ function randomDigits(length: number): string {
|
||||
return result
|
||||
}
|
||||
|
||||
// ============ 模式切换 Tab ============
|
||||
function ModeTabs({
|
||||
mode,
|
||||
onModeChange,
|
||||
}: {
|
||||
mode: "single" | "batch"
|
||||
onModeChange: (mode: "single" | "batch") => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex bg-gray-50 rounded-xl p-1 w-48 max-md:w-full">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "single"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("single")}
|
||||
>
|
||||
单个注册
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 py-2 text-sm font-medium rounded-lg transition-all cursor-pointer",
|
||||
mode === "batch"
|
||||
? "bg-white text-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700",
|
||||
)}
|
||||
onClick={() => onModeChange("batch")}
|
||||
>
|
||||
批量注册
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 连接设备数选择器 ============
|
||||
function ConnectCountSelector({
|
||||
value,
|
||||
@@ -176,50 +139,15 @@ function CouponSelector() {
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 批量注册说明 ============
|
||||
function BatchInfo() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28 md:pt-1">
|
||||
说明:
|
||||
</Label>
|
||||
<div className="flex-1 text-sm text-gray-600 leading-relaxed p-2 bg-blue-50/70 rounded-xl border border-blue-100">
|
||||
<p>
|
||||
批量注册的账号会使用【账号前缀】+【开始数】+【个数】顺序进行注册,
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
如:注册账号前缀为【user】开始数为【2】个数为【10】,则注册的账号为:
|
||||
<span className="text-blue-600 font-medium">
|
||||
{" "}
|
||||
user2,user3,user4,....user11
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============ 主页面 ============
|
||||
export default function BuyPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const product = location.state?.product as Product | undefined
|
||||
|
||||
// 模式
|
||||
const [mode, setMode] = useState<"single" | "batch">("single")
|
||||
|
||||
// 单个注册
|
||||
const [account, setAccount] = useState("")
|
||||
const [pwd, setPwd] = useState("")
|
||||
const [connectCount, setConnectCount] = useState(1)
|
||||
|
||||
// 批量注册
|
||||
const [batchAccount, setBatchAccount] = useState("")
|
||||
const [batchPwd, setBatchPwd] = useState("")
|
||||
const [batchStart, setBatchStart] = useState(1)
|
||||
const [batchCount, setBatchCount] = useState(1)
|
||||
const [batchConnectCount, setBatchConnectCount] = useState(1)
|
||||
|
||||
const [payType, setPayType] = useState("100")
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
@@ -240,12 +168,8 @@ export default function BuyPage() {
|
||||
navigate("/product", { replace: true })
|
||||
return
|
||||
}
|
||||
const acct = randomChars(2) + randomDigits(4)
|
||||
const pw = randomDigits(3)
|
||||
setAccount(acct)
|
||||
setPwd(pw)
|
||||
setBatchAccount(randomChars(3))
|
||||
setBatchPwd(pw)
|
||||
setAccount(randomChars(2) + randomDigits(4))
|
||||
setPwd(randomDigits(3))
|
||||
}, [navigate, product])
|
||||
|
||||
if (!product) return null
|
||||
@@ -271,69 +195,65 @@ export default function BuyPage() {
|
||||
return total
|
||||
}
|
||||
|
||||
const singleTotal = clampMinPrice(
|
||||
const total = clampMinPrice(
|
||||
product.price * displayConnectCount,
|
||||
displayConnectCount,
|
||||
)
|
||||
const batchTotal = clampMinPrice(
|
||||
product.price * batchConnectCount * batchCount,
|
||||
batchConnectCount * batchCount,
|
||||
)
|
||||
const total = mode === "single" ? singleTotal : batchTotal
|
||||
|
||||
// 批量预览
|
||||
const batchPreview = (() => {
|
||||
if (!batchAccount) return ""
|
||||
const end = batchStart + batchCount - 1
|
||||
if (batchCount <= 4) {
|
||||
const items: string[] = []
|
||||
for (let i = batchStart; i <= end; i++) {
|
||||
items.push(`${batchAccount}${i}`)
|
||||
}
|
||||
return items.join(", ")
|
||||
}
|
||||
return `${batchAccount}${batchStart}, ${batchAccount}${batchStart + 1}, ${batchAccount}${batchStart + 2}...${batchAccount}${end}`
|
||||
})()
|
||||
|
||||
const handlePay = async () => {
|
||||
if (mode === "single") {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!batchAccount.trim() || !batchPwd.trim()) {
|
||||
toast.error("账号前缀和密码不能为空")
|
||||
return
|
||||
}
|
||||
if (batchCount > 500) {
|
||||
toast.error("一次最多注册500个")
|
||||
return
|
||||
}
|
||||
// 测试套餐:免费领取
|
||||
const handleClaim = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const result = await createTestAccount({
|
||||
ProductId: product.productId,
|
||||
PackageId: packageId,
|
||||
Account: account.trim(),
|
||||
Pwd: pwd.trim(),
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
toast.success("领取成功")
|
||||
navigate("/admin/channels")
|
||||
} else {
|
||||
toast.error(result.message || "领取失败")
|
||||
}
|
||||
} catch {
|
||||
toast.error("网络错误,请重试")
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!account.trim() || !pwd.trim()) {
|
||||
toast.error("账号和密码不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
// 支付类型/通道映射:1余额 100支付宝 70微信;30微信 50支付宝 70余额
|
||||
const oPayType = Number(payType)
|
||||
const useBalance = oPayType === 1 ? 1 : 0
|
||||
const payChannel = oPayType === 70 ? 30 : 50
|
||||
const useBalance = oPayType === 1
|
||||
const payChannel = oPayType === 1 ? 70 : oPayType === 70 ? 30 : 50
|
||||
|
||||
const params: CreateOrderRequest = {
|
||||
Account: account.trim(),
|
||||
PackageId: packageId,
|
||||
OrderType: mode === "single" ? 1 : 2,
|
||||
Account: mode === "single" ? account.trim() : batchAccount.trim(),
|
||||
Pwd: mode === "single" ? pwd.trim() : batchPwd.trim(),
|
||||
ConnectCount:
|
||||
mode === "single" ? displayConnectCount : batchConnectCount,
|
||||
Pwd: pwd.trim(),
|
||||
ConnectCount: displayConnectCount,
|
||||
OrderType: 1,
|
||||
Price: product.price,
|
||||
CouponAmount: 0,
|
||||
CouponId: 0,
|
||||
UseAccountAmount: useBalance,
|
||||
OPayType: oPayType,
|
||||
PayChannel: payChannel,
|
||||
Price: product.price,
|
||||
...(mode === "batch" && {
|
||||
MinPostfix: batchStart,
|
||||
MaxPostfix: batchCount,
|
||||
}),
|
||||
UseAccountAmount: useBalance,
|
||||
}
|
||||
|
||||
const result = await createOrder(params)
|
||||
@@ -343,7 +263,6 @@ export default function BuyPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// 参考 juipnet Product/buy.cshtml:
|
||||
// Data == "00" 为余额支付直接成功;否则需要微信/支付宝支付
|
||||
const data = result.data
|
||||
if (typeof data === "string") {
|
||||
@@ -381,9 +300,6 @@ export default function BuyPage() {
|
||||
|
||||
const handleBack = () => navigate(-1)
|
||||
|
||||
// 天卡首单(0.1元)不显示批量注册;天卡>0.1元时显示(juipnet:应付款!=0.1时显示批量tab)
|
||||
const showTabs = !isTest && !isFirstOrderDayCard
|
||||
|
||||
return (
|
||||
<Wrap className="bg-white min-h-screen flex items-start justify-center pt-24">
|
||||
<RealNameCheck />
|
||||
@@ -392,249 +308,162 @@ export default function BuyPage() {
|
||||
<div className="text-center mb-6 space-y-2">
|
||||
<h4 className="text-2xl font-bold">
|
||||
<span className="text-gray-800">{product.title}</span>
|
||||
<span className="text-orange-500 ml-2">¥{total.toFixed(2)}</span>
|
||||
<span className="text-orange-500 ml-2">
|
||||
{isTest ? "免费领取" : `¥${total.toFixed(2)}`}
|
||||
</span>
|
||||
</h4>
|
||||
<p className="text-sm text-amber-600">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
{isDayCard && (
|
||||
<p className="text-sm text-amber-600">天卡不支持退款,请谨慎购买</p>
|
||||
{isTest ? (
|
||||
<p className="text-sm text-amber-600">
|
||||
测试套餐免费领取,领取成功后可在 IP账号管理 中查看
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-amber-600">
|
||||
请务必选好所需物品,换货会产生费用
|
||||
</p>
|
||||
{isDayCard && (
|
||||
<p className="text-sm text-amber-600">
|
||||
天卡不支持退款,请谨慎购买
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab 切换 */}
|
||||
{showTabs && (
|
||||
<div className="flex justify-center mb-3">
|
||||
<ModeTabs mode={mode} onModeChange={setMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-5 mb-20">
|
||||
{mode === "single" ? (
|
||||
// ====== 单个注册 ======
|
||||
<>
|
||||
{/* IP产品账号 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品账号:
|
||||
</Label>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
4至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isTest && (
|
||||
<ConnectCountSelector
|
||||
value={displayConnectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
disabled={isFirstOrderDayCard}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// ====== 批量注册 ======
|
||||
<>
|
||||
{/* 批量说明 */}
|
||||
<BatchInfo />
|
||||
|
||||
{/* IP账号前缀 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP账号前缀:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchAccount}
|
||||
onChange={e => setBatchAccount(e.target.value)}
|
||||
placeholder="3-8位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
3至8位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 开始号 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
开始号:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchStart}
|
||||
onChange={e =>
|
||||
setBatchStart(Number.parseInt(e.target.value, 10) || 1)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1 md:max-w-32"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
本批次账号的起始账号
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 注册个数 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
注册个数:
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={batchCount}
|
||||
onChange={e =>
|
||||
setBatchCount(
|
||||
Math.min(500, Number.parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
}
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1 md:max-w-32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 账号预览 */}
|
||||
{batchPreview && (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
即将生成的账号:
|
||||
</Label>
|
||||
<span className="text-sm text-gray-700 font-medium">
|
||||
{batchPreview}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<Input
|
||||
value={batchPwd}
|
||||
onChange={e => setBatchPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 单账号连接设备数 */}
|
||||
<ConnectCountSelector
|
||||
value={batchConnectCount}
|
||||
onChange={setBatchConnectCount}
|
||||
label="单账号连接设备数"
|
||||
hint="每个账号可同时在线设备的数量"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 优惠券 */}
|
||||
{!isTest && <CouponSelector />}
|
||||
|
||||
{/* 余额 */}
|
||||
{/* IP产品账号 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
余额:
|
||||
IP产品账号:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 w-full md:w-auto md:flex-1">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">¥10.00</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={account}
|
||||
onChange={e => setAccount(e.target.value)}
|
||||
placeholder="4-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
4至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
{/* IP产品密码 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
支付方式:
|
||||
IP产品密码:
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={payType}
|
||||
onValueChange={setPayType}
|
||||
className="flex flex-wrap gap-x-8 gap-y-3 md:gap-8"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={balance} alt="余额" className="w-5 h-5" />
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
value={pwd}
|
||||
onChange={e => setPwd(e.target.value)}
|
||||
placeholder="1-10位字母或数字"
|
||||
className="h-10 rounded-xl border-gray-200 w-full md:w-auto md:flex-1"
|
||||
/>
|
||||
<span className="text-xs text-gray-400">
|
||||
1至10位字母或数字或组合
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 总金额 / 应付款 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
总金额:
|
||||
</Label>
|
||||
<span className="font-medium text-gray-800">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
应付款:
|
||||
</Label>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{!isTest && (
|
||||
<ConnectCountSelector
|
||||
value={displayConnectCount}
|
||||
onChange={setConnectCount}
|
||||
label="连接设备数"
|
||||
hint="一个账号可同时在线设备数"
|
||||
disabled={isFirstOrderDayCard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isTest && (
|
||||
<>
|
||||
{/* 优惠券 */}
|
||||
<CouponSelector />
|
||||
|
||||
{/* 余额 */}
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
余额:
|
||||
</Label>
|
||||
<div className="flex items-center gap-2 w-full md:w-auto md:flex-1">
|
||||
<span className="text-sm text-gray-600">
|
||||
当前账户余额
|
||||
<span className="font-bold text-orange-500 ml-1">
|
||||
¥10.00
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-sm text-blue-500 p-0 h-auto font-medium"
|
||||
onClick={() => navigate("/admin")}
|
||||
>
|
||||
去充值
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 支付方式 */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
支付方式:
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={payType}
|
||||
onValueChange={setPayType}
|
||||
className="flex flex-wrap gap-x-8 gap-y-3 md:gap-8"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="100" id="alipay" />
|
||||
<Label
|
||||
htmlFor="alipay"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={alipay} alt="支付宝" className="w-5 h-5" />
|
||||
支付宝
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="70" id="wechat" />
|
||||
<Label
|
||||
htmlFor="wechat"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={wechat} alt="微信" className="w-5 h-5" />
|
||||
微信
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="1" id="balance" />
|
||||
<Label
|
||||
htmlFor="balance"
|
||||
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
|
||||
>
|
||||
<img src={balance} alt="余额" className="w-5 h-5" />
|
||||
余额
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 总金额 / 应付款 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
总金额:
|
||||
</Label>
|
||||
<span className="font-medium text-gray-800">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6">
|
||||
<Label className="text-sm font-medium text-gray-700 md:whitespace-nowrap md:w-28">
|
||||
应付款:
|
||||
</Label>
|
||||
<span className="text-xl font-bold text-orange-500">
|
||||
¥{total.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button
|
||||
@@ -644,13 +473,23 @@ export default function BuyPage() {
|
||||
>
|
||||
上一步
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handlePay}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : "确认支付"}
|
||||
</Button>
|
||||
{isTest ? (
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handleClaim}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "领取中..." : "免费领取"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="flex-1 h-12 rounded-xl text-base bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 transition-all"
|
||||
onClick={handlePay}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "提交中..." : "确认支付"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,12 @@
|
||||
import { useRef, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { fetchApiIndex } from "@/api/dashboard"
|
||||
import {
|
||||
calcDxblPrice,
|
||||
calcDxbtPrice,
|
||||
calcGamePrice,
|
||||
calcUnlimitedPrice,
|
||||
createHttpOrder,
|
||||
fetchGameCities,
|
||||
fetchGameLineCount,
|
||||
@@ -58,6 +62,9 @@ const DURATION_OPTIONS = [
|
||||
{ value: "360", label: "3小时~6小时" },
|
||||
]
|
||||
|
||||
// 短效无限量:有效时长固定 1~5 分钟
|
||||
const UNLIMITED_DURATION_OPTIONS = [{ value: "5", label: "1~5分钟" }]
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: "1", label: "按天" },
|
||||
{ value: "7", label: "按周" },
|
||||
@@ -65,6 +72,22 @@ const PERIOD_OPTIONS = [
|
||||
{ value: "90", label: "按季" },
|
||||
]
|
||||
|
||||
const PERIOD_UNIT: Record<string, string> = {
|
||||
"1": "天",
|
||||
"7": "周",
|
||||
"30": "月",
|
||||
"90": "季",
|
||||
}
|
||||
|
||||
// 短效无限量:周期含按小时(1060),默认按小时
|
||||
const UNLIMITED_PERIOD_OPTIONS = [
|
||||
{ value: "1060", label: "按小时", unit: "小时" },
|
||||
{ value: "1", label: "按天", unit: "天" },
|
||||
{ value: "7", label: "按周", unit: "周" },
|
||||
{ value: "30", label: "按月", unit: "月" },
|
||||
{ value: "90", label: "按季", unit: "季" },
|
||||
]
|
||||
|
||||
const IP_AMOUNT_OPTIONS = [
|
||||
{ value: 5000, label: "每日最多使用 5,000 个IP" },
|
||||
{ value: 10000, label: "每日最多使用 10,000 个IP" },
|
||||
@@ -125,6 +148,30 @@ export default function HttpPage() {
|
||||
const [ipAmount, setIpAmount] = useState(5000)
|
||||
const [volumeAmount, setVolumeAmount] = useState(1)
|
||||
|
||||
// 短效无限量:周期类型/购买时长/白名单数量 + 服务端单价
|
||||
// 默认"按天":当前后端不支持按小时档位(返回默认桩价格),按天/周/月/季均为有效档位
|
||||
const [unlimitedPeriod, setUnlimitedPeriod] = useState("1")
|
||||
const [unlimitedPeriodCount, setUnlimitedPeriodCount] = useState(1)
|
||||
const [unlimitedUnitPrice, setUnlimitedUnitPrice] = useState(0)
|
||||
const [unlimitedPriceLoading, setUnlimitedPriceLoading] = useState(false)
|
||||
const unlimitedPriceSeqRef = useRef(0)
|
||||
|
||||
// 短效包天:服务端每IP每天单价
|
||||
const [dxbtUnitPrice, setDxbtUnitPrice] = useState(0)
|
||||
const [dxbtPriceLoading, setDxbtPriceLoading] = useState(false)
|
||||
const dxbtPriceSeqRef = useRef(0)
|
||||
|
||||
// 短效包量:服务端每1元可购IP数量
|
||||
const [dxblIpsPerYuan, setDxblIpsPerYuan] = useState(0)
|
||||
const [dxblPriceLoading, setDxblPriceLoading] = useState(false)
|
||||
const dxblPriceSeqRef = useRef(0)
|
||||
|
||||
// 短效包天/包量 H币支付弹窗
|
||||
const [dxPayOpen, setDxPayOpen] = useState(false)
|
||||
const [dxPayKind, setDxPayKind] = useState<"daily" | "volume">("daily")
|
||||
const [dxPaying, setDxPaying] = useState(false)
|
||||
const [dxBalance, setDxBalance] = useState(0)
|
||||
|
||||
// 长效游戏
|
||||
const [game, _setGame] = useState<GameOrderData>(DEFAULT_GAME)
|
||||
const gameRef = useRef(game)
|
||||
@@ -136,6 +183,9 @@ export default function HttpPage() {
|
||||
const [gamePriceLoading, setGamePriceLoading] = useState(false)
|
||||
const gameSeqRef = useRef(0)
|
||||
|
||||
// IP数量滑杆:拖动即时更新数值,停止 300ms 后才触发算价(防抖)
|
||||
const ipSliderTimerRef = useRef<number | null>(null)
|
||||
|
||||
// 支付弹窗状态
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
const [payAmount, setPayAmount] = useState(0)
|
||||
@@ -153,6 +203,10 @@ export default function HttpPage() {
|
||||
const [gamePaying, setGamePaying] = useState(false)
|
||||
const alipayBoxRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// 短效无限量支付(H币)
|
||||
const [unlimitedPayOpen, setUnlimitedPayOpen] = useState(false)
|
||||
const [unlimitedPaying, setUnlimitedPaying] = useState(false)
|
||||
|
||||
const setGame = (next: GameOrderData) => {
|
||||
gameRef.current = next
|
||||
_setGame(next)
|
||||
@@ -281,6 +335,33 @@ export default function HttpPage() {
|
||||
applyGame({ ...gameRef.current, gameId })
|
||||
}
|
||||
|
||||
// IP数量滑杆变化:即时更新显示,防抖后重新算价
|
||||
const handleIpSliderChange = (value: number) => {
|
||||
setGame({ ...gameRef.current, ipAmount: value })
|
||||
if (ipSliderTimerRef.current !== null) {
|
||||
window.clearTimeout(ipSliderTimerRef.current)
|
||||
}
|
||||
ipSliderTimerRef.current = window.setTimeout(() => {
|
||||
ipSliderTimerRef.current = null
|
||||
const seq = gameSeqRef.current + 1
|
||||
gameSeqRef.current = seq
|
||||
loadGamePrice(gameRef.current, seq)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// H币余额:优先走 .NET /user/ApiIndex 的 ju_money(登录会话内稳定可用),
|
||||
// 失败时回退 php-api 的 get_balance(依赖 php-api 域名下的 cookie)
|
||||
const loadHCoinBalance = async (): Promise<number | null> => {
|
||||
const apiRes = await fetchApiIndex()
|
||||
if (apiRes.success && apiRes.data) {
|
||||
const m = apiRes.data.UserModel?.ju_money
|
||||
if (typeof m === "number" && Number.isFinite(m)) return m
|
||||
}
|
||||
const phpRes = await fetchHttpBalance()
|
||||
if (phpRes.success) return phpRes.data
|
||||
return null
|
||||
}
|
||||
|
||||
const openGamePay = async () => {
|
||||
if (!ensureLogin()) return
|
||||
if (lineCountRef.current <= 0) {
|
||||
@@ -291,12 +372,12 @@ export default function HttpPage() {
|
||||
toast.error("价格获取失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
const res = await fetchHttpBalance()
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
const hCoin = await loadHCoinBalance()
|
||||
if (hCoin === null) {
|
||||
toast.error("余额获取失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setBalance(res.data)
|
||||
setBalance(hCoin)
|
||||
setGamePayOpen(true)
|
||||
}
|
||||
|
||||
@@ -340,6 +421,146 @@ export default function HttpPage() {
|
||||
setGamePaying(false)
|
||||
}
|
||||
|
||||
// 短效无限量:打开 H币 支付弹窗
|
||||
const openUnlimitedPay = async () => {
|
||||
if (!ensureLogin()) return
|
||||
if (unlimitedTotal <= 0) {
|
||||
toast.error("价格获取失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
const hCoin = await loadHCoinBalance()
|
||||
if (hCoin === null) {
|
||||
toast.error("余额获取失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
setBalance(hCoin)
|
||||
setUnlimitedPayOpen(true)
|
||||
}
|
||||
|
||||
const payUnlimitedOrder = async () => {
|
||||
if (!ensureLogin()) return
|
||||
setUnlimitedPaying(true)
|
||||
const res = await createHttpOrder({
|
||||
order_type: 2,
|
||||
money: unlimitedTotal,
|
||||
pay_type: 1,
|
||||
data: {
|
||||
durationType: 5,
|
||||
periodType: Number(unlimitedPeriod),
|
||||
periodAmount: unlimitedPeriodCount,
|
||||
maxWhitelist: whitelistCount,
|
||||
},
|
||||
})
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
setUnlimitedPaying(false)
|
||||
return
|
||||
}
|
||||
const result = res.data
|
||||
if (result.code === 1) {
|
||||
setUnlimitedPayOpen(false)
|
||||
toast.success(result.msg || "支付成功")
|
||||
} else if (result.code === 2) {
|
||||
if (result.data && alipayBoxRef.current) {
|
||||
alipayBoxRef.current.innerHTML = result.data
|
||||
alipayBoxRef.current.querySelector("form")?.submit()
|
||||
setUnlimitedPayOpen(false)
|
||||
} else {
|
||||
toast.error(result.msg || "支付宝支付数据异常")
|
||||
}
|
||||
} else if (result.code === 3) {
|
||||
if (result.data) {
|
||||
setQrContent(result.data)
|
||||
setQrCodeOpen(true)
|
||||
setUnlimitedPayOpen(false)
|
||||
} else {
|
||||
toast.error(result.msg || "微信支付数据异常")
|
||||
}
|
||||
} else {
|
||||
toast.error(result.msg || "支付失败")
|
||||
}
|
||||
setUnlimitedPaying(false)
|
||||
}
|
||||
|
||||
// 打开短效包天/包量 H币支付弹窗
|
||||
const openDxPay = async (kind: "daily" | "volume") => {
|
||||
if (!ensureLogin()) return
|
||||
if (kind === "daily") {
|
||||
if (effectiveDailyIpAmount < 500) {
|
||||
toast.error("ip数量最低500个")
|
||||
return
|
||||
}
|
||||
if (dxbtUnitPrice <= 0) {
|
||||
toast.error("价格计算中,请稍后重试")
|
||||
return
|
||||
}
|
||||
} else if (effectiveVolumeAmount <= 0) {
|
||||
toast.error("请输入有效的充值金额")
|
||||
return
|
||||
}
|
||||
setDxPayKind(kind)
|
||||
setDxPayOpen(true)
|
||||
const hCoin = await loadHCoinBalance()
|
||||
if (hCoin !== null) setDxBalance(hCoin)
|
||||
}
|
||||
|
||||
// 短效包天/包量下单(H币支付,与 payUnlimitedOrder 一致)
|
||||
const confirmDxPay = async () => {
|
||||
setDxPaying(true)
|
||||
const orderInfo =
|
||||
dxPayKind === "daily"
|
||||
? {
|
||||
order_type: 3,
|
||||
money: dxbtTotal,
|
||||
pay_type: 1,
|
||||
data: {
|
||||
durationType: Number(duration),
|
||||
ipAmount: effectiveDailyIpAmount,
|
||||
periodType: Number(period),
|
||||
periodAmount: periodCount,
|
||||
},
|
||||
}
|
||||
: {
|
||||
order_type: 4,
|
||||
money: effectiveVolumeAmount,
|
||||
pay_type: 1,
|
||||
data: {
|
||||
durationType: Number(duration),
|
||||
ipAmount: 500,
|
||||
},
|
||||
}
|
||||
const res = await createHttpOrder(orderInfo)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
setDxPaying(false)
|
||||
return
|
||||
}
|
||||
const result = res.data
|
||||
if (result.code === 1) {
|
||||
setDxPayOpen(false)
|
||||
toast.success(result.msg || "支付成功")
|
||||
} else if (result.code === 2) {
|
||||
if (result.data && alipayBoxRef.current) {
|
||||
alipayBoxRef.current.innerHTML = result.data
|
||||
alipayBoxRef.current.querySelector("form")?.submit()
|
||||
setDxPayOpen(false)
|
||||
} else {
|
||||
toast.error(result.msg || "支付宝支付数据异常")
|
||||
}
|
||||
} else if (result.code === 3) {
|
||||
if (result.data) {
|
||||
setQrContent(result.data)
|
||||
setQrCodeOpen(true)
|
||||
setDxPayOpen(false)
|
||||
} else {
|
||||
toast.error(result.msg || "微信支付数据异常")
|
||||
}
|
||||
} else {
|
||||
toast.error(result.msg || "支付失败")
|
||||
}
|
||||
setDxPaying(false)
|
||||
}
|
||||
|
||||
const handleTabClick = (id: string) => {
|
||||
setActiveTab(id)
|
||||
if (id === "game") {
|
||||
@@ -352,6 +573,97 @@ export default function HttpPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 短效无限量:向服务端查询单价并计算总价
|
||||
useEffect(() => {
|
||||
if (activeTab !== "unlimited") return
|
||||
const seq = ++unlimitedPriceSeqRef.current
|
||||
setUnlimitedPriceLoading(true)
|
||||
calcUnlimitedPrice({
|
||||
durationType: 5,
|
||||
periodType: Number(unlimitedPeriod),
|
||||
periodAmount: unlimitedPeriodCount,
|
||||
maxWhitelist: whitelistCount,
|
||||
}).then(res => {
|
||||
if (seq !== unlimitedPriceSeqRef.current) return
|
||||
setUnlimitedPriceLoading(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setUnlimitedUnitPrice(res.data)
|
||||
})
|
||||
}, [activeTab, unlimitedPeriod, unlimitedPeriodCount, whitelistCount])
|
||||
|
||||
// 短效无限量总价:periodAmount * whitelist * max(0.6, 1-0.1*(whitelist-1)) * 单价
|
||||
const unlimitedTotal = Number(
|
||||
(
|
||||
unlimitedPeriodCount *
|
||||
whitelistCount *
|
||||
Math.max(0.6, 1 - 0.1 * (whitelistCount - 1)) *
|
||||
unlimitedUnitPrice
|
||||
).toFixed(2),
|
||||
)
|
||||
|
||||
// 短效包天有效IP数 / 短效包量有效金额(非法输入回退默认值)
|
||||
const parsedCustomIp = Number(customIpAmount)
|
||||
const effectiveDailyIpAmount =
|
||||
customIpAmount !== "" &&
|
||||
Number.isFinite(parsedCustomIp) &&
|
||||
parsedCustomIp > 0
|
||||
? parsedCustomIp
|
||||
: ipAmount
|
||||
const parsedCustomAmount = Number(customAmount)
|
||||
const effectiveVolumeAmount =
|
||||
customAmount !== "" &&
|
||||
Number.isFinite(parsedCustomAmount) &&
|
||||
parsedCustomAmount > 0
|
||||
? parsedCustomAmount
|
||||
: volumeAmount
|
||||
|
||||
// 短效包天:向服务端查询每IP每天单价
|
||||
useEffect(() => {
|
||||
if (activeTab !== "daily") return
|
||||
const seq = ++dxbtPriceSeqRef.current
|
||||
setDxbtPriceLoading(true)
|
||||
calcDxbtPrice({
|
||||
durationType: Number(duration),
|
||||
ipAmount: effectiveDailyIpAmount,
|
||||
periodType: Number(period),
|
||||
periodAmount: periodCount,
|
||||
}).then(res => {
|
||||
if (seq !== dxbtPriceSeqRef.current) return
|
||||
setDxbtPriceLoading(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setDxbtUnitPrice(res.data.price)
|
||||
})
|
||||
}, [activeTab, duration, effectiveDailyIpAmount, period, periodCount])
|
||||
|
||||
// 短效包量:向服务端查询每1元可购IP数量
|
||||
useEffect(() => {
|
||||
if (activeTab !== "volume") return
|
||||
const seq = ++dxblPriceSeqRef.current
|
||||
setDxblPriceLoading(true)
|
||||
calcDxblPrice({ durationType: Number(duration), ipAmount: 500 }).then(
|
||||
res => {
|
||||
if (seq !== dxblPriceSeqRef.current) return
|
||||
setDxblPriceLoading(false)
|
||||
if (!res.success) {
|
||||
toast.error(res.message)
|
||||
return
|
||||
}
|
||||
setDxblIpsPerYuan(res.data.ipAmount)
|
||||
},
|
||||
)
|
||||
}, [activeTab, duration])
|
||||
|
||||
// 短效包天总价:periodAmount * price * ipAmount(文档公式)
|
||||
const dxbtTotal = Number(
|
||||
(periodCount * dxbtUnitPrice * effectiveDailyIpAmount).toFixed(2),
|
||||
)
|
||||
|
||||
// 计算价格
|
||||
const calculatePrice = () => {
|
||||
// 根据不同的 tab 计算价格
|
||||
@@ -361,13 +673,13 @@ export default function HttpPage() {
|
||||
price = Number(customAmount) || 1
|
||||
break
|
||||
case "unlimited":
|
||||
price = whitelistCount * periodCount * 0.5
|
||||
price = unlimitedTotal
|
||||
break
|
||||
case "daily":
|
||||
price = ipAmount * periodCount * 0.01
|
||||
price = dxbtTotal
|
||||
break
|
||||
case "volume":
|
||||
price = volumeAmount || Number(customAmount) || 0
|
||||
price = effectiveVolumeAmount
|
||||
break
|
||||
default:
|
||||
price = 0
|
||||
@@ -530,12 +842,8 @@ export default function HttpPage() {
|
||||
|
||||
<OptionBox label="有效时长">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{DURATION_OPTIONS.map(opt => (
|
||||
<OptionBtn
|
||||
key={opt.value}
|
||||
active={duration === opt.value}
|
||||
onClick={() => setDuration(opt.value)}
|
||||
>
|
||||
{UNLIMITED_DURATION_OPTIONS.map(opt => (
|
||||
<OptionBtn key={opt.value} active>
|
||||
{opt.label}
|
||||
</OptionBtn>
|
||||
))}
|
||||
@@ -544,11 +852,11 @@ export default function HttpPage() {
|
||||
|
||||
<OptionBox label="套餐周期">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PERIOD_OPTIONS.map(opt => (
|
||||
{UNLIMITED_PERIOD_OPTIONS.map(opt => (
|
||||
<OptionBtn
|
||||
key={opt.value}
|
||||
active={period === opt.value}
|
||||
onClick={() => setPeriod(opt.value)}
|
||||
active={unlimitedPeriod === opt.value}
|
||||
onClick={() => setUnlimitedPeriod(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionBtn>
|
||||
@@ -558,9 +866,13 @@ export default function HttpPage() {
|
||||
|
||||
<OptionBox label="购买时长">
|
||||
<Stepper
|
||||
value={periodCount}
|
||||
unit="小时"
|
||||
onChange={setPeriodCount}
|
||||
value={unlimitedPeriodCount}
|
||||
unit={
|
||||
UNLIMITED_PERIOD_OPTIONS.find(
|
||||
opt => opt.value === unlimitedPeriod,
|
||||
)?.unit ?? "小时"
|
||||
}
|
||||
onChange={setUnlimitedPeriodCount}
|
||||
/>
|
||||
</OptionBox>
|
||||
|
||||
@@ -572,6 +884,8 @@ export default function HttpPage() {
|
||||
onChange={v => setWhitelistCount(v)}
|
||||
min={1}
|
||||
max={1000}
|
||||
step={10}
|
||||
stepDown={1}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
您的白名单上限{whitelistCount}个,
|
||||
@@ -590,7 +904,11 @@ export default function HttpPage() {
|
||||
</div>
|
||||
</OptionBox>
|
||||
|
||||
<PriceBar price={totalPrice} onPay={openPayDialog} />
|
||||
<PriceBar
|
||||
price={unlimitedTotal}
|
||||
loading={unlimitedPriceLoading}
|
||||
onPay={openUnlimitedPay}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -615,8 +933,11 @@ export default function HttpPage() {
|
||||
{IP_AMOUNT_OPTIONS.map(opt => (
|
||||
<OptionBtn
|
||||
key={opt.value}
|
||||
active={ipAmount === opt.value}
|
||||
onClick={() => setIpAmount(opt.value)}
|
||||
active={customIpAmount === "" && ipAmount === opt.value}
|
||||
onClick={() => {
|
||||
setIpAmount(opt.value)
|
||||
setCustomIpAmount("")
|
||||
}}
|
||||
className="justify-center"
|
||||
>
|
||||
{opt.label}
|
||||
@@ -628,13 +949,21 @@ export default function HttpPage() {
|
||||
每日最多使用
|
||||
<Input
|
||||
type="number"
|
||||
value={customIpAmount}
|
||||
value={
|
||||
customIpAmount === ""
|
||||
? String(ipAmount)
|
||||
: customIpAmount
|
||||
}
|
||||
onChange={e => setCustomIpAmount(e.target.value)}
|
||||
placeholder="IP数"
|
||||
min={500}
|
||||
className="w-28 h-7 inline-block text-center"
|
||||
/>
|
||||
个IP
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
最低 500 个IP
|
||||
</span>
|
||||
</div>
|
||||
</OptionBox>
|
||||
|
||||
@@ -669,12 +998,16 @@ export default function HttpPage() {
|
||||
<OptionBox label="购买时长">
|
||||
<Stepper
|
||||
value={periodCount}
|
||||
unit="天"
|
||||
unit={PERIOD_UNIT[period] ?? "天"}
|
||||
onChange={setPeriodCount}
|
||||
/>
|
||||
</OptionBox>
|
||||
|
||||
<PriceBar price={totalPrice} onPay={openPayDialog} />
|
||||
<PriceBar
|
||||
price={dxbtTotal}
|
||||
loading={dxbtPriceLoading}
|
||||
onPay={() => openDxPay("daily")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -699,8 +1032,11 @@ export default function HttpPage() {
|
||||
{VOLUME_AMOUNTS.map(amount => (
|
||||
<OptionBtn
|
||||
key={amount}
|
||||
active={volumeAmount === amount}
|
||||
onClick={() => setVolumeAmount(amount)}
|
||||
active={customAmount === "" && volumeAmount === amount}
|
||||
onClick={() => {
|
||||
setVolumeAmount(amount)
|
||||
setCustomAmount("")
|
||||
}}
|
||||
className="justify-center"
|
||||
>
|
||||
{amount}元
|
||||
@@ -712,9 +1048,14 @@ export default function HttpPage() {
|
||||
自定义
|
||||
<Input
|
||||
type="number"
|
||||
value={customAmount}
|
||||
value={
|
||||
customAmount === ""
|
||||
? String(volumeAmount)
|
||||
: customAmount
|
||||
}
|
||||
onChange={e => setCustomAmount(e.target.value)}
|
||||
placeholder="金额"
|
||||
min={1}
|
||||
className="w-28 h-7 inline-block text-center"
|
||||
/>
|
||||
元
|
||||
@@ -740,17 +1081,21 @@ export default function HttpPage() {
|
||||
<div className="text-sm text-muted-foreground">
|
||||
可使用{" "}
|
||||
<span className="text-xl font-bold text-blue-500">
|
||||
{Math.floor(
|
||||
(volumeAmount || customAmount
|
||||
? Number(customAmount || volumeAmount)
|
||||
: 0) * 100,
|
||||
)}
|
||||
{dxblPriceLoading
|
||||
? "计算中..."
|
||||
: Math.floor(
|
||||
effectiveVolumeAmount * dxblIpsPerYuan,
|
||||
).toLocaleString()}
|
||||
</span>{" "}
|
||||
个IP,有效期1年,提取免费,使用才扣费。
|
||||
</div>
|
||||
</OptionBox>
|
||||
|
||||
<PriceBar price={totalPrice} onPay={openPayDialog} />
|
||||
<PriceBar
|
||||
price={effectiveVolumeAmount}
|
||||
loading={dxblPriceLoading}
|
||||
onPay={() => openDxPay("volume")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -907,17 +1252,38 @@ export default function HttpPage() {
|
||||
</OptionBox>
|
||||
|
||||
<OptionBox label="IP数量">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Stepper
|
||||
value={game.ipAmount}
|
||||
unit="个"
|
||||
onChange={v => refreshGamePrice({ ...game, ipAmount: v })}
|
||||
min={1}
|
||||
max={lineCount}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
最大可选数量:{lineCount}
|
||||
</span>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Stepper
|
||||
value={game.ipAmount}
|
||||
unit="个"
|
||||
onChange={v =>
|
||||
refreshGamePrice({ ...game, ipAmount: v })
|
||||
}
|
||||
min={1}
|
||||
max={lineCount}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
最大可选数量:{lineCount}
|
||||
</span>
|
||||
</div>
|
||||
{/* 进度条滑杆:拖动即时更新,停顿后算价 */}
|
||||
<div className="flex items-center gap-3 w-full max-w-md">
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={Math.max(1, lineCount)}
|
||||
step={1}
|
||||
value={game.ipAmount}
|
||||
onChange={e =>
|
||||
handleIpSliderChange(Number(e.target.value))
|
||||
}
|
||||
className="flex-1 accent-blue-500 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
当前选择:{game.ipAmount} 个IP
|
||||
</p>
|
||||
</div>
|
||||
</OptionBox>
|
||||
|
||||
@@ -1049,45 +1415,34 @@ export default function HttpPage() {
|
||||
</Dialog>
|
||||
|
||||
{/* 长效游戏支付弹窗(H币支付) */}
|
||||
<Dialog open={gamePayOpen} onOpenChange={setGamePayOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
待支付:¥{gamePrice.toFixed(2)}元
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<HCoinPayDialog
|
||||
open={gamePayOpen}
|
||||
onOpenChange={setGamePayOpen}
|
||||
amount={gamePrice}
|
||||
balance={balance}
|
||||
paying={gamePaying}
|
||||
onPay={() => payGameOrder()}
|
||||
/>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-600">
|
||||
个人剩余H币:
|
||||
<span className="text-orange-500 font-medium ml-1">
|
||||
{balance.toFixed(2)}
|
||||
</span>
|
||||
<a
|
||||
href="/product/http"
|
||||
className="text-blue-500 hover:underline ml-2"
|
||||
>
|
||||
去充值
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
本次支付H币:{gamePrice.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 短效无限量支付弹窗(H币支付) */}
|
||||
<HCoinPayDialog
|
||||
open={unlimitedPayOpen}
|
||||
onOpenChange={setUnlimitedPayOpen}
|
||||
amount={unlimitedTotal}
|
||||
balance={balance}
|
||||
paying={unlimitedPaying}
|
||||
onPay={() => payUnlimitedOrder()}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => payGameOrder()}
|
||||
disabled={gamePaying}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 py-3"
|
||||
>
|
||||
{gamePaying ? "支付中..." : "立即支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* 短效包天/包量支付弹窗(H币支付) */}
|
||||
<HCoinPayDialog
|
||||
open={dxPayOpen}
|
||||
onOpenChange={setDxPayOpen}
|
||||
amount={dxPayKind === "daily" ? dxbtTotal : effectiveVolumeAmount}
|
||||
balance={dxBalance}
|
||||
paying={dxPaying}
|
||||
onPay={() => confirmDxPay()}
|
||||
/>
|
||||
|
||||
{/* 支付宝表单渲染容器 */}
|
||||
<div ref={alipayBoxRef} className="hidden" />
|
||||
@@ -1120,6 +1475,64 @@ export default function HttpPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function HCoinPayDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
amount,
|
||||
balance,
|
||||
paying,
|
||||
onPay,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
amount: number
|
||||
balance: number
|
||||
paying: boolean
|
||||
onPay: () => void
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">
|
||||
待支付:¥{amount.toFixed(2)}元
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
<div className="text-center py-6">
|
||||
<p className="text-gray-600">
|
||||
个人剩余H币:
|
||||
<span className="text-orange-500 font-medium ml-1">
|
||||
{balance.toFixed(2)}
|
||||
</span>
|
||||
<a
|
||||
href="/product/http"
|
||||
className="text-blue-500 hover:underline ml-2"
|
||||
>
|
||||
去充值
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-gray-600 mt-2">
|
||||
本次支付H币:{amount.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onPay}
|
||||
disabled={paying}
|
||||
className="w-full bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 py-3"
|
||||
>
|
||||
{paying ? "支付中..." : "立即支付"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function PrepaidCardCustom({
|
||||
amount,
|
||||
onAmountChange,
|
||||
@@ -1279,17 +1692,22 @@ function Stepper({
|
||||
onChange,
|
||||
min = 1,
|
||||
max,
|
||||
step = 1,
|
||||
stepDown,
|
||||
}: {
|
||||
value: number
|
||||
unit: string
|
||||
onChange: (v: number) => void
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
stepDown?: number
|
||||
}) {
|
||||
const downStep = stepDown ?? step
|
||||
return (
|
||||
<span className="inline-flex items-center rounded-md border overflow-hidden">
|
||||
<button
|
||||
onClick={() => onChange(Math.max(min, value - 1))}
|
||||
onClick={() => onChange(Math.max(min, value - downStep))}
|
||||
disabled={value <= min}
|
||||
className="px-3 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
@@ -1300,7 +1718,9 @@ function Stepper({
|
||||
{unit}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onChange(max ? Math.min(max, value + 1) : value + 1)}
|
||||
onClick={() =>
|
||||
onChange(max ? Math.min(max, value + step) : value + step)
|
||||
}
|
||||
disabled={max !== undefined && value >= max}
|
||||
className="px-3 py-2 bg-linear-to-r from-blue-500 to-cyan-400 text-white hover:opacity-90 disabled:opacity-40 transition-opacity cursor-pointer"
|
||||
>
|
||||
@@ -1325,7 +1745,7 @@ function PriceBar({
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
价格:
|
||||
<span className="text-lg font-bold">
|
||||
{loading ? "计算中..." : `¥${price.toFixed(2)}`}
|
||||
{loading ? "" : `¥${price.toFixed(2)}`}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function ProductPage() {
|
||||
const isShuidi = currentBrand?.id === "水滴独享IP"
|
||||
|
||||
const handlePoolBuy = (pool: PoolModule) => {
|
||||
if (pool.id !== "dial") return
|
||||
if (!pool.canBuy) return
|
||||
navigate("/product/buy-static", { state: { pool } })
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export default function ProductGrid({ products }: ProductGridProps) {
|
||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||
onClick={() => handleBuy(product)}
|
||||
>
|
||||
立即购买
|
||||
{product.isTest ? "免费领取" : "立即购买"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,18 @@ export default function ProductTabs({
|
||||
activeId,
|
||||
onTabChange,
|
||||
}: ProductTabsProps) {
|
||||
|
||||
const visibleTabs = tabs.filter(tab => tab.id !== "window")
|
||||
|
||||
return (
|
||||
<Tabs value={activeId} onValueChange={onTabChange} className="w-full">
|
||||
<TabsList className="grid w-full h-auto bg-transparent p-0 grid-cols-3">
|
||||
{tabs.map(tab => (
|
||||
<TabsList
|
||||
className={cn(
|
||||
"grid w-full h-auto bg-transparent p-0",
|
||||
visibleTabs.length === 2 ? "grid-cols-2" : "grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{visibleTabs.map(tab => (
|
||||
<TabsTrigger
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
@@ -33,4 +41,4 @@ export default function ProductTabs({
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +1,52 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
export type PoolModule = {
|
||||
id: string
|
||||
title: string
|
||||
features: string[]
|
||||
mainTitle: string
|
||||
leftTitle: string
|
||||
rightTitle: string
|
||||
features: {
|
||||
title: string
|
||||
desc: string
|
||||
}[]
|
||||
canBuy?: boolean
|
||||
productType?: "static" | "solid"
|
||||
}
|
||||
|
||||
const POOLS: PoolModule[] = [
|
||||
{
|
||||
id: "house",
|
||||
title: "纯住宅池",
|
||||
features: [
|
||||
"分散式真实住宅IP",
|
||||
"带宽充足,IP段优质分布广",
|
||||
"适合对IP要求高的业务",
|
||||
"IP3-7天周期性变化",
|
||||
"线路切换每天20次",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "dial",
|
||||
title: "多拨池",
|
||||
mainTitle: "拨号线路(静态)",
|
||||
leftTitle: "静态专线介绍",
|
||||
rightTitle: "传统socks5",
|
||||
features: [
|
||||
"集中拨号住宅,带宽较高",
|
||||
"IP段较多、稳定性较高",
|
||||
"IP3-7天周期性变化",
|
||||
"线路切换每天20次",
|
||||
{ title: "运营商专线宽带,自营机房", desc: "天翼云,各种云线路" },
|
||||
{ title: "大出口流量,直播/项目首选", desc: "低带宽出口,共享严重,线路拥挤" },
|
||||
{ title: "全新vpn隧道,安全可靠", desc: "传统协议,波动大,延迟大" },
|
||||
{ title: "全局代理不易发现", desc: "系统局部代理,易发现,易封号" },
|
||||
{ title: "IP周期性变化", desc: "-" },
|
||||
{ title: "精选超20000条不同线路", desc: "容量一般只有2-3000线路" },
|
||||
{ title: "Ros/爱快/路由器/手机/电脑", desc: "电脑/手机" },
|
||||
{ title: "华为级维护专员7*24小时服务", desc: "售后困难,处理时间漫长" },
|
||||
],
|
||||
canBuy: true,
|
||||
productType: "static",
|
||||
},
|
||||
{
|
||||
id: "fixed",
|
||||
title: "固定池",
|
||||
mainTitle: "水滴专线(固态)",
|
||||
leftTitle: "固态专线介绍",
|
||||
rightTitle: "传统socks5",
|
||||
features: [
|
||||
"天翼云节点",
|
||||
"IP长久有效IP不变化,稳定性高",
|
||||
"线路切换单日限制为20次",
|
||||
{ title: "运营商专线宽带,更稳定", desc: "天翼云,各种云线路" },
|
||||
{ title: "全新vpn隧道,安全可靠", desc: "传统socks5协议,波动大,延迟大" },
|
||||
{ title: "全局代理,为项目而生", desc: "系统局部代理,易发现,易封号" },
|
||||
{ title: "IP永久固定不变", desc: "-" },
|
||||
{ title: "精选超10000条不同线路", desc: "容量一般只有2-3000线路" },
|
||||
{ title: "Ros/爱快/路由器/手机/电脑", desc: "电脑/手机" },
|
||||
{ title: "华为级维护专员7*24小时服务", desc: "售后困难,处理时间漫长" },
|
||||
],
|
||||
canBuy: true,
|
||||
productType: "solid",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -47,39 +56,72 @@ type ShuidiPoolsProps = {
|
||||
|
||||
export default function ShuidiPools({ onBuy }: ShuidiPoolsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-6 gap-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 mt-6 gap-8">
|
||||
{POOLS.map(pool => (
|
||||
<div
|
||||
key={pool.id}
|
||||
className="rounded-xl shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white p-6 flex flex-col min-h-60"
|
||||
className="rounded-xl shadow-xl shadow-slate-200/50 bg-white flex flex-col overflow-hidden border border-slate-200"
|
||||
>
|
||||
<div className="text-lg font-bold text-center text-gray-700">
|
||||
{pool.title}
|
||||
<div className="bg-[#e6f0fa] py-4">
|
||||
<div className="text-lg sm:text-xl font-bold text-center text-gray-800">
|
||||
{pool.mainTitle}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-500 space-y-1.5 mt-5">
|
||||
{pool.features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-center gap-2 text-amber-500"
|
||||
>
|
||||
{feature}
|
||||
<div className="flex grow">
|
||||
<div className="w-1/2 bg-white py-6 pl-4 pr-1 flex flex-col">
|
||||
<div className="text-center font-bold text-emerald-600 text-base sm:text-lg h-8 flex items-center justify-center">
|
||||
{pool.leftTitle}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-col grow">
|
||||
{pool.features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[1fr_auto] gap-2 items-center py-2"
|
||||
>
|
||||
<div className="text-sm text-gray-800 font-medium text-center">
|
||||
{feature.title}
|
||||
</div>
|
||||
<div className="text-base font-bold leading-none text-[#10b981]">
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 bg-[#e5e7eb] py-6 pr-4 pl-1 flex flex-col">
|
||||
<div className="text-center font-bold text-emerald-600 text-base sm:text-lg h-8 flex items-center justify-center">
|
||||
{pool.rightTitle}
|
||||
</div>
|
||||
<div className="flex flex-col grow">
|
||||
{pool.features.map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-[auto_1fr] gap-2 items-center py-2"
|
||||
>
|
||||
<div className="text-base font-bold leading-none text-[#ef4444]">
|
||||
✗
|
||||
</div>
|
||||
<div className="text-sm text-gray-800 font-medium text-center ">
|
||||
{feature.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="pt-6 mt-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-blue-500 border-blue-400 hover:bg-blue-50"
|
||||
onClick={() => pool.canBuy && onBuy(pool)}
|
||||
disabled={!pool.canBuy}
|
||||
>
|
||||
立即购买
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
className="w-full bg-[#119a49] hover:bg-[#0d7a39] text-white font-bold text-lg py-4 transition-colors"
|
||||
onClick={() => pool.canBuy && onBuy(pool)}
|
||||
disabled={!pool.canBuy}
|
||||
>
|
||||
立即购买
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,27 @@ export type GameOrderData = {
|
||||
periodAmount: number
|
||||
}
|
||||
|
||||
export type DxwxlData = {
|
||||
durationType: number
|
||||
periodType: number
|
||||
periodAmount: number
|
||||
maxWhitelist: number
|
||||
}
|
||||
|
||||
// 短效包天:有效时长 5/25/180/360,周期 1060-小时 1-天 7-周 30-月 90-季
|
||||
export type DxbtData = {
|
||||
durationType: number
|
||||
ipAmount: number // 每日IP数量,最小500
|
||||
periodType: number
|
||||
periodAmount: number
|
||||
}
|
||||
|
||||
// 短效包量:有效时长 5/25/180/360
|
||||
export type DxblData = {
|
||||
durationType: number
|
||||
ipAmount: number
|
||||
}
|
||||
|
||||
export type GameCity = {
|
||||
code: number
|
||||
name: string
|
||||
@@ -27,7 +48,7 @@ export type HttpOrderInfo = {
|
||||
money: number
|
||||
pay_type: number
|
||||
// 预储值(order_type=1)下单时 data 为空对象
|
||||
data: GameOrderData | Record<string, never>
|
||||
data: GameOrderData | DxwxlData | DxbtData | DxblData | Record<string, never>
|
||||
}
|
||||
|
||||
export type CreateOrderResult = {
|
||||
|
||||
@@ -39,19 +39,28 @@ export type OrderItem = {
|
||||
DeleteTag: number
|
||||
}
|
||||
|
||||
// 产品购买(不含水滴)
|
||||
export type CreateOrderRequest = {
|
||||
PackageId: number
|
||||
OrderType: number
|
||||
Account: string
|
||||
PackageId: number
|
||||
Pwd: string
|
||||
ConnectCount: number
|
||||
MinPostfix?: number
|
||||
MaxPostfix?: number
|
||||
CouponId?: number
|
||||
UseAccountAmount?: number
|
||||
// 订单类型:1新开 2批量新开 3续费 4批量续费 5退款
|
||||
OrderType: number
|
||||
// 套餐单价(后端按 单价×连接数×账号数 计算总价)
|
||||
Price: number
|
||||
// 优惠券减免金额
|
||||
CouponAmount: number
|
||||
// 优惠券ID
|
||||
CouponId: number
|
||||
// 支付类型:1余额 100支付宝 70微信
|
||||
OPayType: number
|
||||
PayChannel?: number
|
||||
Price?: number
|
||||
// 支付通道:30微信 50支付宝 70余额
|
||||
PayChannel: number
|
||||
// 是否使用余额
|
||||
UseAccountAmount: boolean
|
||||
// 代理下用户会员号(可选)
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export type CreateTestAccountRequest = {
|
||||
@@ -128,11 +137,13 @@ export const DEFAULT_FILTERS: OrderFilters = {
|
||||
eTime: "",
|
||||
}
|
||||
|
||||
// 与后端 Hncore.Pass.Vpn.Domain.OrderType 枚举保持一致
|
||||
export const ORDER_TYPE_LABELS: Record<number, string> = {
|
||||
1: "新开",
|
||||
2: "续费",
|
||||
3: "批量新开",
|
||||
2: "批量新开",
|
||||
3: "续费",
|
||||
4: "批量续费",
|
||||
5: "退款",
|
||||
}
|
||||
|
||||
export const PACKAGE_OPTIONS = [
|
||||
|
||||
97
src/lib/models/shuidi.ts
Normal file
97
src/lib/models/shuidi.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// ==================== 水滴静态:游戏列表 ====================
|
||||
|
||||
export type ShuidiGame = {
|
||||
id: number
|
||||
head?: number
|
||||
name: string
|
||||
}
|
||||
|
||||
// ==================== 水滴静态:套餐列表 ====================
|
||||
|
||||
export type ShuidiPackage = {
|
||||
Id: string
|
||||
TenantId: string
|
||||
ProductId: string
|
||||
Name: string
|
||||
PackageType: string
|
||||
Title: string | null
|
||||
Image: string
|
||||
Status: string
|
||||
Profile: string
|
||||
Price: string
|
||||
LinePrice: string
|
||||
DayPrice: string
|
||||
MinPrice: string
|
||||
DayCount: string
|
||||
OriginKey: string
|
||||
OriginName: string
|
||||
CreateTime: string
|
||||
UpdateTime: string
|
||||
DeleteTag: string
|
||||
IsTest: string
|
||||
package_name: string
|
||||
}
|
||||
|
||||
// 带宽档位(test / 2M / 4M / 10M / 20M)-> 该档位下的套餐(天卡/周卡/月卡...)
|
||||
export type ShuidiPackageGroup = Record<string, ShuidiPackage[]>
|
||||
|
||||
export type ShuidiPackageList = {
|
||||
game: ShuidiPackageGroup
|
||||
line: ShuidiPackageGroup
|
||||
}
|
||||
|
||||
// ==================== 水滴静态:线路列表 ====================
|
||||
|
||||
export type ShuidiLineLevel = {
|
||||
total: number
|
||||
line_level_en_name: string
|
||||
province: number | string
|
||||
}
|
||||
|
||||
export type ShuidiCity = {
|
||||
total: number
|
||||
discount?: number
|
||||
isp_class: number
|
||||
name: string
|
||||
province: string
|
||||
rid: string
|
||||
selected_num: number
|
||||
city?: string | number
|
||||
ping?: number
|
||||
}
|
||||
|
||||
export type ShuidiProvince = {
|
||||
total: number
|
||||
province_count: number
|
||||
ping: number
|
||||
line_levels: Record<string, ShuidiLineLevel>
|
||||
en_name: string
|
||||
citys: ShuidiCity[]
|
||||
}
|
||||
|
||||
export type ShuidiRegion = {
|
||||
surplus_out_total?: number
|
||||
province_surplus_outs: Record<string, ShuidiProvince>
|
||||
}
|
||||
|
||||
// ==================== 水滴静态:下单 ====================
|
||||
// POST /jinyou/jinyoujt/create_order -> { code, data, msg }
|
||||
|
||||
export type ShuidiOrderInfo = {
|
||||
pay_type: number // 10-余额 70-微信 100-支付宝
|
||||
type: 0 | 1 // 0-静态 1-固态
|
||||
package_id: string
|
||||
game_id: number | "" // 无项目时为空字符串
|
||||
buy_num: number
|
||||
price: number
|
||||
account: string
|
||||
password: string
|
||||
// 省-市-rid-100 -> 数量(字符串);未选择时为空数组
|
||||
product_info: Record<string, string> | []
|
||||
}
|
||||
|
||||
export type ShuidiOrderResult = {
|
||||
code: number // 1-开通成功 2-支付宝 3-微信 -1-失败
|
||||
msg?: string
|
||||
data?: string // 支付宝/微信支付主体
|
||||
}
|
||||
@@ -25,11 +25,28 @@ export default defineConfig({
|
||||
target: "http://192.168.3.6:5000",
|
||||
bypass(req) {
|
||||
if (req.headers?.accept?.includes("text/html")) {
|
||||
return "/index.html"
|
||||
// 仅前端 SPA 路由回退 index.html;续费等后端老页面(rebuyindex/rebuy)
|
||||
// 需要直达后端渲染
|
||||
const pathname = new URL(req.url ?? "", "http://localhost").pathname
|
||||
const spaPaths = [
|
||||
"/product",
|
||||
"/product/buy",
|
||||
"/product/buy-static",
|
||||
"/product/http",
|
||||
"/product/routeros",
|
||||
]
|
||||
if (spaPaths.includes(pathname)) {
|
||||
return "/index.html"
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
"/script": "https://php-api.juip.com",
|
||||
"/jinyou": {
|
||||
target: "https://php-api.juip.com",
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
"/http": {
|
||||
target: "https://php-api.juip.com",
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user