修复登录注册验证码bug
This commit is contained in:
@@ -1,11 +1,26 @@
|
||||
import { callPublic } from "./base"
|
||||
import { clearSession, readCookie, setSession } from "./token"
|
||||
import { clearCookie, clearSession, readCookie, setSession } from "./token"
|
||||
|
||||
function parseUserInfo(raw: string | null): unknown {
|
||||
if (!raw) return null
|
||||
let decoded = raw
|
||||
try {
|
||||
decoded = decodeURIComponent(raw)
|
||||
} catch {
|
||||
decoded = raw
|
||||
}
|
||||
try {
|
||||
return JSON.parse(decoded)
|
||||
} catch {
|
||||
return decoded
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiLogin(props: {
|
||||
logincode: string
|
||||
password: string
|
||||
}): Promise<{ success: boolean; message?: string }> {
|
||||
const result = await callPublic("/user/ApiLogin", {
|
||||
const result = await callPublic<unknown>("/user/ApiLogin", {
|
||||
Logincode: props.logincode,
|
||||
Password: props.password,
|
||||
})
|
||||
@@ -14,23 +29,15 @@ export async function apiLogin(props: {
|
||||
return { success: false, message: result.message || "用户名或者密码不正确" }
|
||||
}
|
||||
|
||||
const token = readCookie("token")
|
||||
const userInfoRaw = readCookie("userInfo")
|
||||
// 通过响应头下发,优先读响应头,cookie 兜底
|
||||
const token = result.headers?.token ?? readCookie("token")
|
||||
const userInfoRaw = result.headers?.userinfo ?? readCookie("userInfo")
|
||||
|
||||
if (!token) {
|
||||
return { success: false, message: "登录成功但未获取到 token" }
|
||||
}
|
||||
|
||||
let userInfo: unknown = userInfoRaw
|
||||
if (userInfoRaw) {
|
||||
try {
|
||||
userInfo = JSON.parse(userInfoRaw)
|
||||
} catch {
|
||||
userInfo = userInfoRaw
|
||||
}
|
||||
}
|
||||
|
||||
setSession(token, userInfo)
|
||||
setSession(token, parseUserInfo(userInfoRaw))
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
@@ -59,5 +66,7 @@ export async function apiRegister(props: {
|
||||
|
||||
export async function logout() {
|
||||
clearSession()
|
||||
clearCookie("token")
|
||||
clearCookie("userInfo")
|
||||
window.location.href = "/"
|
||||
}
|
||||
|
||||
108
src/api/base.ts
108
src/api/base.ts
@@ -8,14 +8,31 @@ import { clearSession, getUserToken } from "./token"
|
||||
|
||||
type CallMode = "public" | "device" | "user"
|
||||
|
||||
const DEFAULT_TIMEOUT = 15_000
|
||||
|
||||
function captureHeaders(response: Response): Record<string, string> {
|
||||
const headers: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headers[key.toLowerCase()] = value
|
||||
})
|
||||
return headers
|
||||
}
|
||||
|
||||
async function request<R = undefined>(
|
||||
endpoint: string,
|
||||
init: {
|
||||
method: "GET" | "POST"
|
||||
body?: string
|
||||
auth?: string
|
||||
timeout?: number
|
||||
},
|
||||
): Promise<ApiResponse<R>> {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(),
|
||||
init.timeout ?? DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {}
|
||||
if (init.body !== undefined) {
|
||||
@@ -27,8 +44,10 @@ async function request<R = undefined>(
|
||||
method: init.method,
|
||||
headers,
|
||||
body: init.body,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const responseHeaders = captureHeaders(response)
|
||||
const contentType = response.headers.get("Content-Type") ?? "text/plain"
|
||||
|
||||
if (contentType.includes("text/plain")) {
|
||||
@@ -40,7 +59,7 @@ async function request<R = undefined>(
|
||||
message: text || "请求失败",
|
||||
}
|
||||
}
|
||||
return { success: true, data: undefined as R }
|
||||
return { success: true, data: undefined as R, headers: responseHeaders }
|
||||
}
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
@@ -65,25 +84,36 @@ async function request<R = undefined>(
|
||||
return {
|
||||
success: true,
|
||||
data: (json.Data ?? json.data ?? undefined) as R,
|
||||
headers: responseHeaders,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.Message || "请求失败",
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: json }
|
||||
return { success: true, data: json, headers: responseHeaders }
|
||||
}
|
||||
|
||||
throw new Error(`无法解析响应数据: ${contentType}`)
|
||||
} catch (e) {
|
||||
if (controller.signal.aborted) {
|
||||
return {
|
||||
success: false,
|
||||
status: 408,
|
||||
message: "请求超时,请稍后重试",
|
||||
}
|
||||
}
|
||||
console.error("后端请求异常:", e)
|
||||
return {
|
||||
success: false,
|
||||
status: 500,
|
||||
message: (e as Error).message || "网络错误",
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,40 +171,56 @@ async function call<R = undefined>(
|
||||
const body = data === undefined ? undefined : JSON.stringify(data)
|
||||
|
||||
const run = async (): Promise<ApiResponse<R>> => {
|
||||
let auth: string | undefined
|
||||
if (mode === "user") {
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "未登录或会话已过期",
|
||||
}
|
||||
}
|
||||
auth = `Bearer ${token}`
|
||||
} else if (mode === "device") {
|
||||
const token = await acquireDeviceToken()
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "设备凭证获取失败",
|
||||
}
|
||||
}
|
||||
auth = `Bearer ${token}`
|
||||
}
|
||||
const maxAttempts = mode === "device" ? 2 : 1
|
||||
|
||||
const resp = await request<R>(endpoint, { method, body, auth })
|
||||
|
||||
if (!resp.success && resp.status === 401) {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
let auth: string | undefined
|
||||
if (mode === "user") {
|
||||
clearSession()
|
||||
const token = getUserToken()
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "未登录或会话已过期",
|
||||
}
|
||||
}
|
||||
auth = `Bearer ${token}`
|
||||
} else if (mode === "device") {
|
||||
deviceToken = null
|
||||
deviceTokenExpire = 0
|
||||
if (attempt > 1) {
|
||||
// 上次请求 401,强制作废旧 token 再重新获取
|
||||
deviceToken = null
|
||||
deviceTokenExpire = 0
|
||||
}
|
||||
const token = await acquireDeviceToken()
|
||||
if (!token) {
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "设备凭证获取失败",
|
||||
}
|
||||
}
|
||||
auth = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const resp = await request<R>(endpoint, { method, body, auth })
|
||||
|
||||
if (!resp.success && resp.status === 401) {
|
||||
if (mode === "user") {
|
||||
clearSession()
|
||||
return resp
|
||||
}
|
||||
if (mode === "device" && attempt < maxAttempts) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: 401,
|
||||
message: "请求未授权",
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
if (method === "GET" && data === undefined) {
|
||||
|
||||
43
src/api/linedata.ts
Normal file
43
src/api/linedata.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { ApiResponse } from "@/lib/api"
|
||||
import { callPublic } from "./base"
|
||||
|
||||
export type LineItem = {
|
||||
city: string | number
|
||||
daikuan: string | number
|
||||
name: string | number
|
||||
nasname: string | number
|
||||
online: string | number
|
||||
supply: string | number
|
||||
}
|
||||
|
||||
export type LineData = {
|
||||
count: number | string
|
||||
use_count: number | string
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
export type LineSearchResult = {
|
||||
data: LineItem[]
|
||||
}
|
||||
|
||||
export async function fetchLineData(
|
||||
product: number,
|
||||
): Promise<ApiResponse<LineData>> {
|
||||
return await callPublic<LineData>(
|
||||
`/script/linedata/display.php?product=${product}`,
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
|
||||
export async function searchLineData(
|
||||
productid: number,
|
||||
info: string,
|
||||
): Promise<ApiResponse<LineSearchResult>> {
|
||||
const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}`
|
||||
return await callPublic<LineSearchResult>(
|
||||
`/script/linedata/search.php?${query}`,
|
||||
undefined,
|
||||
"GET",
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const AUTH_EVENT = "auth-change"
|
||||
export const AUTH_EVENT = "auth-change"
|
||||
|
||||
const TOKEN_KEY = "auth_token"
|
||||
const USER_KEY = "auth_user"
|
||||
@@ -8,6 +8,11 @@ export function readCookie(name: string): string | null {
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
export function clearCookie(name: string) {
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API 兼容性不足,标准做法是覆盖写入
|
||||
document.cookie = `${name}=; max-age=0; path=/`
|
||||
}
|
||||
|
||||
export function getUserToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { Navigate, useLocation } from "react-router-dom"
|
||||
import { isAuthed } from "@/api/token"
|
||||
import { AUTH_EVENT, isAuthed } from "@/api/token"
|
||||
|
||||
export default function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const location = useLocation()
|
||||
const [isValid] = useState(isAuthed)
|
||||
const [isValid, setIsValid] = useState(isAuthed)
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setIsValid(isAuthed())
|
||||
window.addEventListener(AUTH_EVENT, update)
|
||||
return () => window.removeEventListener(AUTH_EVENT, update)
|
||||
}, [])
|
||||
|
||||
if (!isValid) {
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { NavLink, useNavigate } from "react-router-dom"
|
||||
import { logout } from "@/api/auth"
|
||||
import { isAuthed } from "@/api/token"
|
||||
import { AUTH_EVENT, isAuthed } from "@/api/token"
|
||||
|
||||
export default function ProfileOrLogin() {
|
||||
const [authed, setAuthed] = useState(isAuthed)
|
||||
@@ -10,10 +10,10 @@ export default function ProfileOrLogin() {
|
||||
useEffect(() => {
|
||||
const update = () => setAuthed(isAuthed())
|
||||
window.addEventListener("focus", update)
|
||||
window.addEventListener("auth-change", update)
|
||||
window.addEventListener(AUTH_EVENT, update)
|
||||
return () => {
|
||||
window.removeEventListener("focus", update)
|
||||
window.removeEventListener("auth-change", update)
|
||||
window.removeEventListener(AUTH_EVENT, update)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -1,72 +1,296 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { fetchLineData, type LineItem, searchLineData } from "@/api/linedata"
|
||||
import { fetchProducts } from "@/api/product"
|
||||
import Wrap from "@/components/wrap"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const REFRESH_INTERVAL = 5 * 60 * 1000
|
||||
|
||||
type ProductOption = { id: number; name: string }
|
||||
|
||||
type DisplayRow = {
|
||||
item: LineItem
|
||||
nameSpan: number
|
||||
citySpan: number
|
||||
}
|
||||
|
||||
function toText(value: string | number): string {
|
||||
return value === null || value === undefined ? "" : String(value)
|
||||
}
|
||||
|
||||
function onlineClass(value: string | number): string {
|
||||
const text = toText(value)
|
||||
if (!text) return "text-gray-400"
|
||||
return text.includes("在线") || text.includes("正常")
|
||||
? "text-green-600"
|
||||
: "text-red-500"
|
||||
}
|
||||
|
||||
export default function IpLinePage() {
|
||||
const [products, setProducts] = useState<ProductOption[]>([])
|
||||
const [productId, setProductId] = useState<number | null>(null)
|
||||
const [rows, setRows] = useState<LineItem[]>([])
|
||||
const [count, setCount] = useState<number | string>(0)
|
||||
const [useCount, setUseCount] = useState<number | string>(0)
|
||||
const [keyword, setKeyword] = useState("")
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts().then(result => {
|
||||
if (!result.success) {
|
||||
setError(result.message)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const list = result.data
|
||||
.filter(p => p.Product.OnLine === 1)
|
||||
.sort((a, b) => a.Product.Sort - b.Product.Sort)
|
||||
.map(p => ({ id: p.Product.Id, name: p.Product.Name }))
|
||||
setProducts(list)
|
||||
if (list[0]) {
|
||||
setProductId(list[0].id)
|
||||
} else {
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const loadDisplay = useCallback(async (pid: number) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const result = await fetchLineData(pid)
|
||||
console.log(result, "resultresultresultresult")
|
||||
|
||||
if (result.success) {
|
||||
setRows(result.data.data ?? [])
|
||||
setCount(result.data.count ?? 0)
|
||||
setUseCount(result.data.use_count ?? 0)
|
||||
} else {
|
||||
setRows([])
|
||||
setCount(0)
|
||||
setUseCount(0)
|
||||
setError(result.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (productId !== null) {
|
||||
setSearching(false)
|
||||
setKeyword("")
|
||||
loadDisplay(productId)
|
||||
}
|
||||
}, [productId, loadDisplay])
|
||||
|
||||
useEffect(() => {
|
||||
if (productId === null) return
|
||||
const timer = setInterval(() => {
|
||||
if (!searching) loadDisplay(productId)
|
||||
}, REFRESH_INTERVAL)
|
||||
return () => clearInterval(timer)
|
||||
}, [productId, searching, loadDisplay])
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (productId === null) return
|
||||
const info = keyword.trim()
|
||||
if (!info) {
|
||||
if (searching) {
|
||||
setSearching(false)
|
||||
await loadDisplay(productId)
|
||||
}
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const result = await searchLineData(productId, info)
|
||||
if (result.success) {
|
||||
setRows(result.data.data ?? [])
|
||||
} else {
|
||||
setRows([])
|
||||
setError(result.message)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleCopy = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success("复制成功")
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制")
|
||||
}
|
||||
}
|
||||
|
||||
const currentName = products.find(p => p.id === productId)?.name ?? ""
|
||||
|
||||
const handleExport = () => {
|
||||
if (rows.length === 0) {
|
||||
toast.error("暂无数据可导出")
|
||||
return
|
||||
}
|
||||
const header = [
|
||||
"产品",
|
||||
"城市",
|
||||
"运营商",
|
||||
"服务器域名",
|
||||
"带宽",
|
||||
"服务器状态",
|
||||
]
|
||||
const lines = [
|
||||
header,
|
||||
...rows.map(r => [
|
||||
toText(r.name),
|
||||
toText(r.city),
|
||||
toText(r.supply),
|
||||
toText(r.nasname),
|
||||
toText(r.daikuan),
|
||||
toText(r.online),
|
||||
]),
|
||||
]
|
||||
const csv = lines
|
||||
.map(line => line.map(v => `"${v.replaceAll('"', '""')}"`).join(","))
|
||||
.join("\r\n")
|
||||
const blob = new Blob([`\uFEFF${csv}`], {
|
||||
type: "text/csv;charset=utf-8",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = `${currentName || "IP线路表"}.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success("导出成功")
|
||||
}
|
||||
|
||||
const displayRows = useMemo<DisplayRow[]>(() => {
|
||||
const out: DisplayRow[] = []
|
||||
let i = 0
|
||||
while (i < rows.length) {
|
||||
const item = rows[i]
|
||||
let cityEnd = i + 1
|
||||
while (
|
||||
cityEnd < rows.length &&
|
||||
rows[cityEnd].name === item.name &&
|
||||
rows[cityEnd].city === item.city
|
||||
) {
|
||||
cityEnd += 1
|
||||
}
|
||||
let nameEnd = cityEnd
|
||||
while (nameEnd < rows.length && rows[nameEnd].name === item.name) {
|
||||
nameEnd += 1
|
||||
}
|
||||
for (let k = i; k < cityEnd; k += 1) {
|
||||
out.push({
|
||||
item: rows[k],
|
||||
nameSpan: k === i ? nameEnd - i : 0,
|
||||
citySpan: k === i ? cityEnd - i : 0,
|
||||
})
|
||||
}
|
||||
i = cityEnd
|
||||
}
|
||||
return out
|
||||
}, [rows])
|
||||
|
||||
return (
|
||||
<Wrap className="flex flex-col gap-4 mt-30 max-w-6xl mx-auto px-4 pb-20">
|
||||
<div className="grid grid-cols-3">
|
||||
<div></div>
|
||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||
IP线路表
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm justify-end items-end text-blue-500">
|
||||
<a
|
||||
href="/softDownload"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
下载客户端
|
||||
</a>
|
||||
<a
|
||||
href="/help"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
教程&帮助
|
||||
</a>
|
||||
<>
|
||||
<Toaster position="top-center" richColors />
|
||||
<Wrap className="flex flex-col gap-4 mt-30 max-w-6xl mx-auto px-4 pb-20">
|
||||
<div className="grid grid-cols-3">
|
||||
<div></div>
|
||||
<h3 className="text-3xl font-bold text-gray-800 text-center">
|
||||
IP线路表
|
||||
</h3>
|
||||
<div className="flex gap-4 text-sm justify-end items-end text-blue-500">
|
||||
<a
|
||||
href="/softDownload"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
下载客户端
|
||||
</a>
|
||||
<a
|
||||
href="/help"
|
||||
className="no-underline hover:text-blue-700 active:no-underline focus:no-underline"
|
||||
>
|
||||
教程&帮助
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="flex justify-between gap-4 text-xs text-gray-600 px-2">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
线路表和账户必须为同一产品才能使用
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
请优先选择客户端连接
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
无对应客户端时,可通过线路表直连支持所有设备
|
||||
</li>
|
||||
</ul>
|
||||
<ul className="flex justify-between gap-4 text-xs text-gray-600 px-2">
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
线路表和账户必须为同一产品才能使用
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
请优先选择客户端连接
|
||||
</li>
|
||||
<li className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
无对应客户端时,可通过线路表直连支持所有设备
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button className="px-6 py-1.5 rounded-full text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200 font-medium">
|
||||
极狐IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
极光IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
蘑菇IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
麒麟IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
猎豹IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
水滴独享IP
|
||||
</Button>
|
||||
<Button className="px-6 py-1.5 rounded-full text-gray-600 border border-gray-200 hover:border-blue-400 transition-colors bg-white">
|
||||
火狐静态IP
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{products.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => setProductId(p.id)}
|
||||
className={cn(
|
||||
"px-6 py-1.5 rounded-full text-sm font-medium transition-colors cursor-pointer",
|
||||
p.id === productId
|
||||
? "text-white bg-linear-to-r from-cyan-400 to-blue-500 shadow-md shadow-blue-200"
|
||||
: "text-gray-600 border border-gray-200 hover:border-blue-400 bg-white",
|
||||
)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-4 w-full">
|
||||
<div className="relative w-1/2">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<div className="flex items-center justify-center gap-4 w-full">
|
||||
<div className="relative w-1/2">
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === "Enter") handleSearch()
|
||||
}}
|
||||
placeholder="请输入线路搜索信息,如:混拨"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
className="px-8 py-2 bg-linear-to-r from-cyan-400 to-blue-500 text-white rounded-full text-sm font-medium shadow-md shadow-blue-200 cursor-pointer hover:opacity-90"
|
||||
>
|
||||
搜索当前线路表
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-1.5 px-4 py-2 border border-blue-400 text-blue-500 rounded text-sm bg-white hover:bg-blue-50 transition-colors cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
@@ -77,270 +301,156 @@ export default function IpLinePage() {
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>L2TP密钥:</span>
|
||||
<span className="text-orange-600">1234</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>STTP端口:</span>
|
||||
<span className="text-orange-600">4430</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="混拨"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-cyan-400 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button className="px-8 py-2 bg-linear-to-r from-cyan-400 to-blue-500 text-white rounded-full text-sm font-medium shadow-md shadow-blue-200">
|
||||
搜索当前线路表
|
||||
</button>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 border border-blue-400 text-blue-500 rounded text-sm bg-white hover:bg-blue-50 transition-colors">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
导出
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-100 rounded-lg p-2 flex justify-center items-center gap-8 text-sm font-medium">
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>L2TP密钥:</span>
|
||||
<span className="text-orange-600">1234</span>
|
||||
<div className="flex items-center gap-6 text-xs text-gray-600 px-1">
|
||||
{searching ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
搜索结果:
|
||||
<span className="font-bold text-gray-800">{rows.length}条</span>
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时总线路:
|
||||
<span className="font-bold text-gray-800">{count}条</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时可用线路:
|
||||
<span className="font-bold text-gray-800">{useCount}条</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span className="text-gray-400">
|
||||
{currentName}- (每5分钟更新一次,禁止频繁访问!) :{" "}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-orange-500">
|
||||
<span>STTP端口:</span>
|
||||
<span className="text-orange-600">4430</span>
|
||||
|
||||
<div className="border border-gray-200 rounded overflow-hidden">
|
||||
<table className="w-full text-center text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
|
||||
<tr>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
||||
产品
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-32">
|
||||
城市
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-48">
|
||||
运营商
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200">
|
||||
服务器域名
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
||||
带宽
|
||||
</th>
|
||||
<th className="py-3 px-4 w-24">服务器状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-10 text-gray-400">
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : error && rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-10 text-gray-400">
|
||||
加载失败:{error}
|
||||
<button
|
||||
onClick={() =>
|
||||
productId !== null && loadDisplay(productId)
|
||||
}
|
||||
className="ml-3 text-blue-500 hover:underline cursor-pointer"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-10 text-gray-400">
|
||||
{searching ? "未找到匹配线路" : "暂无线路数据"}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
displayRows.map((row, index) => (
|
||||
<tr key={`${row.item.name}-${row.item.nasname}-${index}`}>
|
||||
{row.nameSpan > 0 && (
|
||||
<td
|
||||
rowSpan={row.nameSpan}
|
||||
className="py-3 px-4 border-r border-gray-200 text-gray-500 align-top"
|
||||
>
|
||||
{toText(row.item.name) || "-"}
|
||||
</td>
|
||||
)}
|
||||
{row.citySpan > 0 && (
|
||||
<td
|
||||
rowSpan={row.citySpan}
|
||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium align-top"
|
||||
>
|
||||
{toText(row.item.city) || "-"}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
{toText(row.item.supply) || "-"}
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>{toText(row.item.nasname) || "-"}</span>
|
||||
{toText(row.item.nasname) && (
|
||||
<button
|
||||
onClick={() => handleCopy(toText(row.item.nasname))}
|
||||
className="ml-2 text-green-500 cursor-pointer hover:text-green-700"
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
{toText(row.item.daikuan) || "-"}
|
||||
</td>
|
||||
<td
|
||||
className={cn("py-3 px-4", onlineClass(row.item.online))}
|
||||
>
|
||||
{toText(row.item.online) || "-"}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-xs text-gray-600 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时总线路:<span className="font-bold text-gray-800">486条</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span>
|
||||
实时可用线路:<span className="font-bold text-gray-800">486条</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-500 rounded-full" />
|
||||
<span className="text-gray-400">
|
||||
极狐IP- (每5分钟更新一次,禁止频繁访问!) :{" "}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-200 rounded overflow-hidden">
|
||||
<table className="w-full text-center text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
|
||||
<tr>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">产品</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-32">城市</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-48">
|
||||
运营商
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200">服务器域名</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-20">IP量</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
||||
实时带宽
|
||||
</th>
|
||||
<th className="py-3 px-4 border-r border-gray-200 w-24">
|
||||
维护状态
|
||||
</th>
|
||||
<th className="py-3 px-4 w-24">负载状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-100">
|
||||
<tr>
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-gray-500"
|
||||
rowSpan={10}
|
||||
>
|
||||
极狐
|
||||
</td>
|
||||
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium"
|
||||
rowSpan={7}
|
||||
>
|
||||
全国
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb2.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb3.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb4.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hb5.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hbdx2.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>hbdx1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium"
|
||||
rowSpan={2}
|
||||
>
|
||||
北京
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>bj1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>bj2.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td
|
||||
className="py-3 px-4 border-r border-gray-200 text-green-600 font-medium"
|
||||
rowSpan={1}
|
||||
>
|
||||
上海
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200 text-gray-500">
|
||||
电信/联通/移动
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">
|
||||
<span>sh1.jhip.net</span>
|
||||
<span className="ml-2 text-green-500 cursor-pointer hover:text-green-700">
|
||||
复制
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">100M</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200">正常</td>
|
||||
<td className="py-3 px-4 border-r border-gray-200"></td>
|
||||
<td className="py-3 px-4"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Wrap>
|
||||
</Wrap>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ type ApiResponse<T = undefined> =
|
||||
| {
|
||||
success: true
|
||||
data: T
|
||||
// 响应头(key 统一小写),用于登录等场景从 header 取数据
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET }
|
||||
|
||||
@@ -23,6 +23,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
},
|
||||
"/script": "http://192.168.3.6:5000",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user