http-预储存/短效无限量/短效包天

This commit is contained in:
Eamon
2026-10-16 17:20:34 +08:00
parent b5076d7ca7
commit 65e1eca221
6 changed files with 628 additions and 85 deletions

View File

@@ -1,6 +1,8 @@
import type { ApiResponse } from "@/lib/api"
import type {
CreateOrderResult,
DxblData,
DxbtData,
DxwxlData,
GameCity,
GameItem,
@@ -31,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) {
@@ -87,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
@@ -140,7 +183,7 @@ export async function phpGet<T = RawHttpResponse>(
// ==================== 响应解析工具 ====================
// 老 PHP 接口习惯把业务数据包在 d 字段里
// 接口习惯把业务数据包在 d 字段里
export function unwrapD(
raw: RawHttpResponse | undefined,
): Record<string, unknown> {
@@ -177,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 {
@@ -189,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 {
@@ -201,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) }
@@ -216,37 +265,83 @@ export async function calcGamePrice(
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,
})
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 priceValue =
typeof raw.price === "number" ? raw.price : unwrapD(raw).price
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: typeof priceValue === "number" ? priceValue : 0,
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) }
}
// ==================== 订单 ====================
@@ -390,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,
@@ -407,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,

View File

@@ -1,10 +1,12 @@
import type { ApiResponse } from "@/lib/api"
import type {
ShuidiGame,
ShuidiOrderInfo,
ShuidiOrderResult,
ShuidiPackageList,
ShuidiRegion,
} from "@/lib/models/shuidi"
import { phpFetch, phpGet, phpPost } from "./http"
import { phpFetch, phpPost } from "./http"
// ==================== 水滴静态/固态(jinyou) ====================
// - 游戏列表:POST /jinyou/api/gameList(不带 cookie)
@@ -63,8 +65,12 @@ export async function fetchShuidiRegions(params: {
is_unshar: String(params.is_unshar),
game_id: String(params.game_id),
})
const res = await phpGet<JinyouResult<{ region?: ShuidiRegion }>>(
// 余量数据时效性强,禁止浏览器缓存
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
@@ -82,3 +88,26 @@ export async function fetchShuidiRegions(params: {
},
}
}
// ==================== 下单 ====================
// 与 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,
},
}
}

View File

@@ -1,15 +1,25 @@
import { ChevronDownIcon } from "lucide-react"
import { Popover } from "radix-ui"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { fetchApiIndex } from "@/api/dashboard"
import {
createShuidiOrder,
fetchShuidiGameList,
fetchShuidiPackages,
fetchShuidiRegions,
} from "@/api/shuidi"
import { QrCodeImage } from "@/components/qrCodeImage"
import RealNameCheck from "@/components/realNameCheck"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
@@ -17,6 +27,7 @@ import Wrap from "@/components/wrap"
import type {
ShuidiCity,
ShuidiGame,
ShuidiOrderInfo,
ShuidiPackage,
ShuidiPackageList,
ShuidiRegion,
@@ -305,7 +316,14 @@ export default function BuyStaticPage() {
const [payType, setPayType] = useState("100")
const [submitting, setSubmitting] = useState(false)
// 初始加载:游戏列表 + 静态套餐(type=1),并生成随机账号密码
// 微信支付二维码弹窗
const [wechatOpen, setWechatOpen] = useState(false)
const [wechatPayUrl, setWechatPayUrl] = useState("")
// 账户余额(.NET /user/ApiIndex -> UserModel.RestAmount)
const [restAmount, setRestAmount] = useState(0)
// 初始加载:游戏列表 + 静态套餐(type=1)+ 账户余额,并生成随机账号密码
useEffect(() => {
setAccount(randomChars(2) + randomDigits(4))
setPwd(randomDigits(3))
@@ -318,16 +336,27 @@ export default function BuyStaticPage() {
if (res.success) setPackages(res.data)
else toast.error(res.message)
})
fetchApiIndex().then(res => {
if (!res.success || !res.data) return
const amount = res.data.UserModel?.RestAmount
if (typeof amount === "number" && Number.isFinite(amount)) {
setRestAmount(amount)
}
})
}, [])
// 线路列表:跟随独享类型/项目变化重新拉取(与 juipnet select_game 逻辑一致)
// 线路列表:跟随独享类型/项目变化重新拉取
// 序列号防止快速切换时旧响应覆盖新数据
const regionSeqRef = useRef(0)
const loadRegions = useCallback(async () => {
const seq = ++regionSeqRef.current
setRegionLoading(true)
const res = await fetchShuidiRegions({
product_id: PRODUCT_ID,
is_unshar: type === "project" ? 0 : 1,
game_id: type === "project" ? gameId : "",
})
if (seq !== regionSeqRef.current) return
if (res.success) {
setRegion(res.data)
const codes = Object.keys(res.data.province_surplus_outs)
@@ -346,7 +375,7 @@ export default function BuyStaticPage() {
loadRegions()
}, [loadRegions])
// 切换项目独享/专线独享:重置套餐与分配选择(与 juipnet game()/line() 逻辑一致)
// 切换项目独享/专线独享:重置套餐与分配选择
const handleTypeChange = (t: "project" | "line") => {
if (t === type) return
setType(t)
@@ -363,6 +392,24 @@ export default function BuyStaticPage() {
[region],
)
// 可用节点总数:优先接口返回的 surplus_out_total,缺失时按城市余量求和兜底
const surplusOutTotal = useMemo(() => {
if (!region) return 0
if (
typeof region.surplus_out_total === "number" &&
region.surplus_out_total >= 0
) {
return region.surplus_out_total
}
return provinceCodes.reduce((sum, code) => {
const cities = region.province_surplus_outs[code]?.citys ?? []
return (
sum +
cities.reduce((s, c) => s + (Number.isFinite(c.total) ? c.total : 0), 0)
)
}, 0)
}, [region, provinceCodes])
// 省市导航行:按拼音首字母 + 名称排序,附带字母索引
const provinceRows = useMemo(() => {
if (!region) return []
@@ -407,8 +454,8 @@ export default function BuyStaticPage() {
() =>
keyword
? provinceRows.filter(r =>
r.province.en_name.toLowerCase().includes(keyword),
)
r.province.en_name.toLowerCase().includes(keyword),
)
: provinceRows,
[provinceRows, keyword],
)
@@ -511,9 +558,9 @@ export default function BuyStaticPage() {
setBuyNum(Math.max(Math.floor(value), sum))
}
// 校验与 juipnet pay_page 一致;下单接口暂未提供,校验通过后提示联系客服
// 校验与 juipnet pay_page 一致;下单与 juipnet pay() 一致
const handlePay = async () => {
if ((region?.surplus_out_total ?? 0) < buyNum) {
if (surplusOutTotal < buyNum) {
toast.error("节点数量不足!")
return
}
@@ -533,9 +580,60 @@ export default function BuyStaticPage() {
toast.error("密码为1-10位字母或数字!")
return
}
// product_info:省-市-rid-100 -> 数量(字符串),未选择时为空数组
const productInfo: Record<string, string> = {}
for (const [key, count] of Object.entries(cityCounts)) {
productInfo[key] = String(count)
}
// 免费(价格0)时固定余额支付,与 juipnet pay_page 一致
const orderInfo: ShuidiOrderInfo = {
pay_type: totalPrice <= 0 ? 10 : Number(payType),
type: 0,
package_id: packageId,
game_id: type === "project" ? gameId : "",
buy_num: buyNum,
price: totalPrice,
account: account.trim(),
password: pwd.trim(),
product_info: Object.keys(productInfo).length > 0 ? productInfo : [],
}
setSubmitting(true)
try {
toast.info("该产品暂未开放线上购买,请联系客服购买")
const result = await createShuidiOrder(orderInfo)
if (!result.success) {
toast.error(result.message)
return
}
const data = result.data
if (data.code === 1) {
toast.success(data.msg || "账号开通成功!")
navigate("/admin/channels")
} else if (data.code === 2 && data.data) {
// 支付宝:data 为支付宝支付主体(HTML 表单),挂载后自动提交
const container = document.createElement("div")
container.innerHTML = data.data
const form = container.querySelector("form")
if (form) {
form.style.display = "none"
document.body.appendChild(form)
form.submit()
} else if (/^https?:\/\//i.test(data.data)) {
window.open(data.data, "_blank", "noopener,noreferrer")
} else {
toast.error("支付宝支付发起失败,请重试")
}
} else if (data.code === 3 && data.data) {
// 微信:data 为支付二维码内容
setWechatPayUrl(data.data)
setWechatOpen(true)
} else {
toast.error(data.msg || "下单失败,请重试")
}
} catch {
toast.error("网络错误,请重试")
} finally {
setSubmitting(false)
}
@@ -637,7 +735,7 @@ export default function BuyStaticPage() {
<p className="text-sm text-gray-600">
可用节点总数:
<span className="font-bold text-blue-600 ml-1">
{region?.surplus_out_total ?? 0}
{surplusOutTotal}
</span>
</p>
@@ -899,7 +997,9 @@ export default function BuyStaticPage() {
<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">¥28.00</span>
<span className="font-bold text-orange-500 ml-1">
¥{restAmount.toFixed(2)}
</span>
</span>
<Button
variant="link"
@@ -942,7 +1042,7 @@ export default function BuyStaticPage() {
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="1" id="bs-balance" />
<RadioGroupItem value="10" id="bs-balance" />
<Label
htmlFor="bs-balance"
className="cursor-pointer text-sm text-gray-700 flex items-center gap-1.5"
@@ -972,6 +1072,40 @@ export default function BuyStaticPage() {
</div>
</div>
</div>
{/* 微信扫码支付弹窗(与 juipnet staticBackdrop 一致) */}
<Dialog open={wechatOpen} onOpenChange={setWechatOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-center">微信扫码支付</DialogTitle>
<DialogDescription className="text-center">
请使用微信扫描下方二维码完成支付
</DialogDescription>
</DialogHeader>
<div className="flex flex-col items-center gap-3">
{wechatPayUrl && (
<div className="rounded-lg border border-gray-200 p-2">
<QrCodeImage text={wechatPayUrl} size={220} />
</div>
)}
<p className="text-sm text-gray-600">
应付金额:
<span className="font-bold text-orange-500 ml-1">
¥{totalPrice.toFixed(2)}
</span>
</p>
<Button
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={() => {
setWechatOpen(false)
navigate("/admin/channels")
}}
>
我已付款
</Button>
</div>
</DialogContent>
</Dialog>
</Wrap>
)
}

View File

@@ -1,7 +1,10 @@
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,
@@ -69,6 +72,13 @@ 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: "小时" },
@@ -139,14 +149,29 @@ export default function HttpPage() {
const [volumeAmount, setVolumeAmount] = useState(1)
// 短效无限量:周期类型/购买时长/白名单数量 + 服务端单价
const [unlimitedPeriod, setUnlimitedPeriod] = useState(
UNLIMITED_PERIOD_OPTIONS[0].value,
)
// 默认"按天":当前后端不支持按小时档位(返回默认桩价格),按天/周/月/季均为有效档位
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)
@@ -158,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)
@@ -307,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) {
@@ -317,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)
}
@@ -366,19 +421,19 @@ export default function HttpPage() {
setGamePaying(false)
}
// 短效无限量:打开 H币 支付弹窗(与 juipnet 一致)
// 短效无限量:打开 H币 支付弹窗
const openUnlimitedPay = async () => {
if (!ensureLogin()) return
if (unlimitedTotal <= 0) {
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)
setUnlimitedPayOpen(true)
}
@@ -427,6 +482,85 @@ export default function HttpPage() {
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") {
@@ -439,7 +573,7 @@ export default function HttpPage() {
}
}
// 短效无限量:向服务端查询单价并计算总价(与 juipnet 一致)
// 短效无限量:向服务端查询单价并计算总价
useEffect(() => {
if (activeTab !== "unlimited") return
const seq = ++unlimitedPriceSeqRef.current
@@ -470,6 +604,66 @@ export default function HttpPage() {
).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 计算价格
@@ -482,10 +676,10 @@ export default function HttpPage() {
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
@@ -739,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}
@@ -752,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>
@@ -793,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>
)}
@@ -823,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}元
@@ -836,12 +1048,16 @@ 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"
/>
{/* 这个金额需要根据选项自动更新和也支持手动显示 */}
元
</span>
</div>
@@ -865,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>
)}
@@ -1032,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>
@@ -1193,6 +1434,16 @@ export default function HttpPage() {
onPay={() => payUnlimitedOrder()}
/>
{/* 短效包天/包量支付弹窗(H币支付) */}
<HCoinPayDialog
open={dxPayOpen}
onOpenChange={setDxPayOpen}
amount={dxPayKind === "daily" ? dxbtTotal : effectiveVolumeAmount}
balance={dxBalance}
paying={dxPaying}
onPay={() => confirmDxPay()}
/>
{/* 支付宝表单渲染容器 */}
<div ref={alipayBoxRef} className="hidden" />
@@ -1494,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

View File

@@ -19,6 +19,20 @@ export type DxwxlData = {
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
@@ -34,7 +48,7 @@ export type HttpOrderInfo = {
money: number
pay_type: number
// 预储值(order_type=1)下单时 data 为空对象
data: GameOrderData | DxwxlData | Record<string, never>
data: GameOrderData | DxwxlData | DxbtData | DxblData | Record<string, never>
}
export type CreateOrderResult = {

View File

@@ -73,3 +73,25 @@ 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 // 支付宝/微信支付主体
}