From 1336a550fe0255036ba3b3b123a188283ede245e Mon Sep 17 00:00:00 2001 From: Eamon <17516219072@163.com> Date: Fri, 14 Aug 2026 16:30:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=99=BB=E5=BD=95=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E9=AA=8C=E8=AF=81=E7=A0=81bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/auth.ts | 37 +- src/api/base.ts | 108 +++-- src/api/linedata.ts | 43 ++ src/api/token.ts | 7 +- src/components/authGuard.tsx | 12 +- src/components/profileOrLogin.tsx | 6 +- src/home/ipLine/index.tsx | 748 +++++++++++++++++------------- src/lib/api.ts | 2 + vite.config.ts | 1 + 9 files changed, 593 insertions(+), 371 deletions(-) create mode 100644 src/api/linedata.ts diff --git a/src/api/auth.ts b/src/api/auth.ts index a40f0f0..68e4b2b 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -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("/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 = "/" } diff --git a/src/api/base.ts b/src/api/base.ts index 7f05192..c8d9999 100644 --- a/src/api/base.ts +++ b/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 { + const headers: Record = {} + response.headers.forEach((value, key) => { + headers[key.toLowerCase()] = value + }) + return headers +} + async function request( endpoint: string, init: { method: "GET" | "POST" body?: string auth?: string + timeout?: number }, ): Promise> { + 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( 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( 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( 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( const body = data === undefined ? undefined : JSON.stringify(data) const run = async (): Promise> => { - 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(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(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) { diff --git a/src/api/linedata.ts b/src/api/linedata.ts new file mode 100644 index 0000000..8796db7 --- /dev/null +++ b/src/api/linedata.ts @@ -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> { + return await callPublic( + `/script/linedata/display.php?product=${product}`, + undefined, + "GET", + ) +} + +export async function searchLineData( + productid: number, + info: string, +): Promise> { + const query = `type=0&productid=${productid}&info=${encodeURIComponent(info)}` + return await callPublic( + `/script/linedata/search.php?${query}`, + undefined, + "GET", + ) +} diff --git a/src/api/token.ts b/src/api/token.ts index b193a8b..041a9ca 100644 --- a/src/api/token.ts +++ b/src/api/token.ts @@ -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) } diff --git a/src/components/authGuard.tsx b/src/components/authGuard.tsx index 693c0d9..cda6f63 100644 --- a/src/components/authGuard.tsx +++ b/src/components/authGuard.tsx @@ -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 ( diff --git a/src/components/profileOrLogin.tsx b/src/components/profileOrLogin.tsx index c678fc5..1596fe8 100644 --- a/src/components/profileOrLogin.tsx +++ b/src/components/profileOrLogin.tsx @@ -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) } }, []) diff --git a/src/home/ipLine/index.tsx b/src/home/ipLine/index.tsx index 52e6d37..5cad875 100644 --- a/src/home/ipLine/index.tsx +++ b/src/home/ipLine/index.tsx @@ -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([]) + const [productId, setProductId] = useState(null) + const [rows, setRows] = useState([]) + const [count, setCount] = useState(0) + const [useCount, setUseCount] = useState(0) + const [keyword, setKeyword] = useState("") + const [searching, setSearching] = useState(false) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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(() => { + 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 ( - -
-
-

- IP线路表 -

- -
    -
  • - - 线路表和账户必须为同一产品才能使用 -
  • -
  • - - 请优先选择客户端连接 -
  • -
  • - - 无对应客户端时,可通过线路表直连支持所有设备 -
  • -
+
    +
  • + + 线路表和账户必须为同一产品才能使用 +
  • +
  • + + 请优先选择客户端连接 +
  • +
  • + + 无对应客户端时,可通过线路表直连支持所有设备 +
  • +
-
- - - - - - - -
+
+ {products.map(p => ( + + ))} +
-
-
-
+
+
+
+ + + +
+ 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" + /> +
+ + +
+ +
+
+ L2TP密钥: + 1234 +
+
+ STTP端口: + 4430
-
- - -
-
-
- L2TP密钥: - 1234 +
+ {searching ? ( +
+ + + 搜索结果: + {rows.length}条 + +
+ ) : ( + <> +
+ + + 实时总线路: + {count}条 + +
+
+ + + 实时可用线路: + {useCount}条 + +
+ + )} +
+ + + {currentName}- (每5分钟更新一次,禁止频繁访问!) :{" "} + +
-
- STTP端口: - 4430 + +
+ + + + + + + + + + + + + {loading ? ( + + + + ) : error && rows.length === 0 ? ( + + + + ) : rows.length === 0 ? ( + + + + ) : ( + displayRows.map((row, index) => ( + + {row.nameSpan > 0 && ( + + )} + {row.citySpan > 0 && ( + + )} + + + + + + )) + )} + +
+ 产品 + + 城市 + + 运营商 + + 服务器域名 + + 带宽 + 服务器状态
+ 加载中... +
+ 加载失败:{error} + +
+ {searching ? "未找到匹配线路" : "暂无线路数据"} +
+ {toText(row.item.name) || "-"} + + {toText(row.item.city) || "-"} + + {toText(row.item.supply) || "-"} + + {toText(row.item.nasname) || "-"} + {toText(row.item.nasname) && ( + + )} + + {toText(row.item.daikuan) || "-"} + + {toText(row.item.online) || "-"} +
-
- -
-
- - - 实时总线路:486条 - -
-
- - - 实时可用线路:486条 - -
-
- - - 极狐IP- (每5分钟更新一次,禁止频繁访问!) :{" "} - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
产品城市 - 运营商 - 服务器域名IP量 - 实时带宽 - - 维护状态 - 负载状态
- 极狐 - - 全国 - - 电信/联通/移动 - - hb1.jhip.net - - 复制 - - 100M正常
- 电信/联通/移动 - - hb2.jhip.net - - 复制 - - 100M正常
- 电信/联通/移动 - - hb3.jhip.net - - 复制 - - 100M正常
- 电信/联通/移动 - - hb4.jhip.net - - 复制 - - 100M正常
- 电信/联通/移动 - - hb5.jhip.net - - 复制 - - 100M正常
- 电信/联通/移动 - - hbdx2.jhip.net - - 复制 - - 100M正常
- 电信/联通/移动 - - hbdx1.jhip.net - - 复制 - - 100M正常
- 北京 - - 电信/联通/移动 - - bj1.jhip.net - - 复制 - - 100M正常
- 电信/联通/移动 - - bj2.jhip.net - - 复制 - - 100M正常
- 上海 - - 电信/联通/移动 - - sh1.jhip.net - - 复制 - - 100M正常
-
- + + ) } diff --git a/src/lib/api.ts b/src/lib/api.ts index b7259ec..d599caf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -17,6 +17,8 @@ type ApiResponse = | { success: true data: T + // 响应头(key 统一小写),用于登录等场景从 header 取数据 + headers?: Record } export { API_BASE_URL, type ApiResponse, CLIENT_ID, CLIENT_SECRET } diff --git a/vite.config.ts b/vite.config.ts index 18b9e06..eba9900 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ } }, }, + "/script": "http://192.168.3.6:5000", }, }, })