更新登录页面和帮助教程

This commit is contained in:
Eamon
2026-08-07 16:54:21 +08:00
parent 038e66d0cc
commit 4c9953ab57
22 changed files with 1278 additions and 133 deletions

View File

@@ -11,7 +11,7 @@ export function AdminNavMenu() {
<ProductDropdown />
</AdminMenuItem>
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
<AdminNavItem to="/softDownload">软件下载</AdminNavItem>
<AdminNavItem to="/softdownload">软件下载</AdminNavItem>
<AdminNavItem to="/help">帮助/教程</AdminNavItem>
</nav>
)
@@ -153,60 +153,60 @@ function ProductDropdown() {
title="动静态IP"
desc="千万级动态短效IP,自动去重,高并发采集"
tag="热门"
onClick={() => handleClick("/product/short")}
onClick={() => handleClick("/product?tab=short")}
/>
<ProductCard
title="新HTTP/S5"
desc="IP可用时长从数小时到365天全覆盖"
tag="稳定"
onClick={() => handleClick("/product/long")}
onClick={() => handleClick("/product?tab=long")}
/>
<ProductCard
title="HTTP/S5"
desc="海量全球住宅优质IP,4G/5G真实手机IP"
tag="全球"
onClick={() => handleClick("/product/global")}
onClick={() => handleClick("/product?tab=global")}
/>
<ProductCard
title="软路由成本价"
desc="云端自动切换IP,更易于调用与开发"
onClick={() => handleClick("/product/tunnel")}
onClick={() => handleClick("/product?tab=tunnel")}
/>
<ProductCard
title="动态Vps"
desc="IP独享,高带宽,灵活控制存活周期"
onClick={() => handleClick("/product/exclusive")}
onClick={() => handleClick("/product?tab=exclusive")}
/>
<ProductCard
title="静态独享IP"
desc="纯净IP,更符合跨境卖家需求的云主机"
onClick={() => handleClick("/product/static")}
onClick={() => handleClick("/product?tab=static")}
/>
</div>
<div className="flex items-center flex-wrap gap-2 pt-2 border-t border-gray-100">
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
<button
onClick={() => handleClick("/product/short")}
onClick={() => handleClick("/product?tab=short")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
短效代理
</button>
<button
onClick={() => handleClick("/product/global")}
onClick={() => handleClick("/product?tab=global")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
海外代理
</button>
<button
onClick={() => handleClick("/product/tunnel")}
onClick={() => handleClick("/product?tab=tunnel")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
隧道代理
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => handleClick("/custom")}
onClick={() => handleClick("/product")}
className="text-xs text-orange-500 hover:underline font-medium whitespace-nowrap"
>
业务定制 →

View File

@@ -0,0 +1,3 @@
export default function LongPage() {
return <div>LongPage</div>
}

View File

@@ -0,0 +1,3 @@
export default function ShortPage() {
return <div>ShortPage</div>
}

View File

@@ -1,11 +1,17 @@
import type { ApiResponse } from "@/lib/api"
import { callByDevice, callByUser } from "./base"
// 用户类型
export interface User {
id: number
username: string
// ...
loginCode: string
phone: string
name: string
wx: string
qq: string
taobao: string
email: string
restAmount: number
}
export type LoginMode = "phone_code" | "password"
@@ -15,8 +21,10 @@ export async function login(props: {
password: string
remember: boolean
mode: LoginMode
code?: string
wx?: string
qq?: string
}): Promise<ApiResponse> {
// 使用 callByDevice 去拿 Token (设备级认证)
const result = await callByDevice<{
access_token: string
refresh_token: string
@@ -29,7 +37,6 @@ export async function login(props: {
if (!result.success) return result
// 登录成功,存入 localStorage
const { access_token, refresh_token, expires_in } = result.data
localStorage.setItem("auth_token", access_token)
localStorage.setItem("auth_refresh", refresh_token)
@@ -52,21 +59,17 @@ export async function logout() {
})
}
// 清除前端本地缓存
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
// 强制跳转到登录页
window.location.href = "/login"
window.location.href = "/"
}
// 获取当前用户信息 (校验 Token 是否有效)
export async function getProfile() {
return await callByUser<User>("/api/auth/introspect")
}
// 刷新 Token
export async function refreshAuth(): Promise<{
access_token: string
refresh_token: string
@@ -88,7 +91,6 @@ export async function refreshAuth(): Promise<{
return null
}
// 刷新成功,更新本地缓存
const { access_token, refresh_token, expires_in } = resp.data
localStorage.setItem("auth_token", access_token)
localStorage.setItem("auth_refresh", refresh_token)

15
src/api/verify.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ApiResponse } from "@/lib/api"
import { callByDevice } from "./base"
export type SMSPurpose = "User_Code" | "FindUser_Code" | "User_Login"
export async function sendSMS(
phone: string,
purpose: SMSPurpose = "User_Code",
): Promise<ApiResponse> {
return await callByDevice("/api/verify/sms", {
phone,
purpose,
key: purpose,
})
}

View File

@@ -8,36 +8,39 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
useEffect(() => {
const checkAuth = async () => {
const accessToken = localStorage.getItem("auth_token")
const refreshToken = localStorage.getItem("auth_refresh")
try {
const accessToken = localStorage.getItem("auth_token")
const refreshToken = localStorage.getItem("auth_refresh")
// 没 Token,判定无效
if (!accessToken && !refreshToken) {
setIsValid(false)
return
}
if (!accessToken && !refreshToken) {
setIsValid(false)
return
}
// 尝试用现有 AccessToken 获取用户信息
const profileResp = await getProfile()
if (profileResp.success) {
setIsValid(true)
return
}
// AccessToken 无效 尝试用 RefreshToken 刷新
if (refreshToken) {
const newTokens = await refreshAuth()
if (newTokens) {
const profileResp = await getProfile()
if (profileResp.success) {
setIsValid(true)
return
}
}
// 刷新失败或无 RefreshToken,为未登录
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
setIsValid(false)
if (refreshToken) {
const newTokens = await refreshAuth()
if (newTokens) {
setIsValid(true)
return
}
}
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
setIsValid(false)
} catch {
localStorage.removeItem("auth_token")
localStorage.removeItem("auth_refresh")
localStorage.removeItem("auth_expires_in")
setIsValid(false)
}
}
checkAuth()
@@ -50,14 +53,14 @@ export default function AuthGuard({ children }: { children: React.ReactNode }) {
</div>
)
// if (!isValid) {
// return (
// <Navigate
// to={`/login?redirect=${encodeURIComponent(location.pathname)}`}
// replace
// />
// )
// }
if (!isValid) {
return (
<Navigate
to={`/login?redirect=${encodeURIComponent(location.pathname)}`}
replace
/>
)
}
return <>{children}</>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,30 @@
import Image from 'next/image';
import android from './images/android.webp';
import android01 from './images/android01.webp';
import android02 from './images/android02.webp';
# 安卓手机设置代理教程
1、打开设置,进入WLAN
<Image
src={android}
alt="IE浏览器设置"
width={'100%'}
margin-top={'0'}
/>
2、选择已连接的WIFI网络,点小箭头进入设置页面
<Image
src={android01}
alt="IE浏览器设置"
width={'100%'}
margin-top={'0'}
/>
3、将代理设置为“手动”,填写主机名,端口信息,点击右上角保存
<Image
src={android02}
alt="IE浏览器设置"
width={'100%'}
margin-top={'0'}
/>
4、打开手机浏览器搜索IP地址查询,若查询结果和设置的代理IP一致,则代理设置成功

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,25 @@
import Image from 'next/image';
import ieConfig from './images/ieConfig.webp';
import lanConfig from './images/lanConfig.webp';
# 浏览器代理设置教程
打开IE浏览器,选择“设置”,点击“Internet选项”,在弹出的“局域网LAN设置”中,代理服务器的复选框打上勾,并填写从蓝狐HTTP获取的ip地址及端口号,点击确定,刷新浏览器,浏览器的IP就改变了。
1、打开IE浏览器,选择“设置”;
<Image
src={ieConfig}
alt="IE浏览器设置"
width={'100%'}
margin-top={'0'}
/>
2、点击“Internet选项”;
3、弹出“Internet选项”弹窗,选择连接—局域网设置;
<Image
src={lanConfig}
alt="IE浏览器设置"
width={'100%'}
margin-top={'0'}
/>
4、在弹出的“局域网LAN设置”中,代理服务器的复选框打上勾,并填写从蓝狐代理获取的ip地址及端口。点击确定,即设置成功了。

View File

@@ -1,8 +1,72 @@
import { useRef, useState, useSyncExternalStore } from "react"
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
import { NavLink, useNavigate } from "react-router-dom"
import { logout } from "@/api/auth"
import Wrap from "@/components/wrap"
import { cn } from "@/lib/utils"
function ProfileOrLogin() {
const [authed, setAuthed] = useState(
() => !!localStorage.getItem("auth_token"),
)
const navigate = useNavigate()
useEffect(() => {
const onFocus = () => setAuthed(!!localStorage.getItem("auth_token"))
window.addEventListener("focus", onFocus)
return () => window.removeEventListener("focus", onFocus)
}, [])
const handleLogout = async () => {
await logout()
}
if (authed) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => navigate("/admin")}
className="w-24 h-12 flex items-center justify-center lg:text-lg text-slate-700 hover:text-blue-500 transition-colors"
>
<span>个人中心</span>
</button>
<button
onClick={handleLogout}
className={[
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
"transition-colors duration-200 ease-in-out",
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
].join(" ")}
>
<span>退出登录</span>
</button>
</div>
)
}
return (
<div className="flex items-center">
<NavLink
to="/login"
state={{ tab: "login" }}
className="w-24 h-12 flex items-center justify-center lg:text-lg"
>
<span>登录</span>
</NavLink>
<NavLink
to="/login"
state={{ tab: "register" }}
className={[
"w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white",
"transition-colors duration-200 ease-in-out",
"from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300",
].join(" ")}
>
<span>注册</span>
</NavLink>
</div>
)
}
export default function Header() {
const scroll = useSyncExternalStore(
callback => {
@@ -47,7 +111,7 @@ export default function Header() {
<ProductDropdown onNavigate={() => setOpenMenu(false)} />
</MenuItem>
<NavItem to="/ipline">IP线路表</NavItem>
<NavItem to="/softDownload">软件下载</NavItem>
<NavItem to="/softdownload">软件下载</NavItem>
<NavItem to="/help">帮助/教程</NavItem>
</ul>
</nav>
@@ -79,31 +143,6 @@ const NavItem = ({
</NavLink>
)
function ProfileOrLogin() {
return (
<div className="flex items-center">
<NavLink
to="/login"
state={{ tab: "login" }}
className="w-24 h-12 flex items-center justify-center lg:text-lg"
>
<span>登录</span>
</NavLink>
<NavLink
to="/login"
state={{ tab: "register" }}
className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-full flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`,
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
].join(" ")}
>
<span>注册</span>
</NavLink>
</div>
)
}
const MenuItem = ({
text,
children,
@@ -206,60 +245,60 @@ const ProductDropdown = ({ onNavigate }: { onNavigate: () => void }) => {
title="动静态IP"
desc="千万级动态短效IP,自动去重,高并发采集"
tag="热门"
onClick={() => handleClick("/product/short")}
onClick={() => handleClick("/product?tab=short")}
/>
<ProductCard
title="新HTTP/S5"
desc="IP可用时长从数小时到365天全覆盖"
tag="稳定"
onClick={() => handleClick("/product/long")}
onClick={() => handleClick("/product?tab=long")}
/>
<ProductCard
title="HTTP/S5"
desc="海量全球住宅优质IP,4G/5G真实手机IP"
tag="全球"
onClick={() => handleClick("/product/global")}
onClick={() => handleClick("/product?tab=global")}
/>
<ProductCard
title="软路由成本价"
desc="云端自动切换IP,更易于调用与开发"
onClick={() => handleClick("/product/tunnel")}
onClick={() => handleClick("/product?tab=tunnel")}
/>
<ProductCard
title="动态Vps"
desc="IP独享,高带宽,灵活控制存活周期"
onClick={() => handleClick("/product/exclusive")}
onClick={() => handleClick("/product?tab=exclusive")}
/>
<ProductCard
title="静态独享IP"
desc="纯净IP,更符合跨境卖家需求的云主机"
onClick={() => handleClick("/product/static")}
onClick={() => handleClick("/product?tab=static")}
/>
</div>
<div className="flex items-center flex-wrap gap-3 pt-3 border-t border-gray-100">
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
<button
onClick={() => handleClick("/product/short")}
onClick={() => handleClick("/product?tab=short")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
短效代理
</button>
<button
onClick={() => handleClick("/product/global")}
onClick={() => handleClick("/product?tab=global")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
海外代理
</button>
<button
onClick={() => handleClick("/product/tunnel")}
onClick={() => handleClick("/product?tab=tunnel")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
隧道代理
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => handleClick("/custom")}
onClick={() => handleClick("/product")}
className="text-sm text-orange-500 hover:underline font-medium whitespace-nowrap"
>
业务定制 →

View File

@@ -1,9 +1,300 @@
import {
ArrowLeftIcon,
ArrowRightIcon,
ChevronRightIcon,
SearchIcon,
} from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import Wrap from "@/components/wrap"
import { ARTICLES, type Article, CATEGORIES } from "@/lib/models/article"
import { cn } from "@/lib/utils"
export default function HelpPage() {
const [search, setSearch] = useState("")
const [activeCategoryId, setActiveCategoryId] = useState<number | null>(null)
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null)
const filtered = useMemo(() => {
let list = ARTICLES
if (activeCategoryId !== null) {
list = list.filter(a => a.catalogId === activeCategoryId)
}
if (search.trim()) {
const kw = search.trim().toLowerCase()
list = list.filter(
a =>
a.title.toLowerCase().includes(kw) ||
a.keyword.toLowerCase().includes(kw) ||
a.content.toLowerCase().includes(kw),
)
}
return list
}, [search, activeCategoryId])
const currentIndex = selectedArticle
? filtered.findIndex(a => a.id === selectedArticle.id)
: -1
const prevArticle = currentIndex > 0 ? filtered[currentIndex - 1] : null
const nextArticle =
currentIndex >= 0 && currentIndex < filtered.length - 1
? filtered[currentIndex + 1]
: null
const handleArticleClick = (article: Article) => {
setSelectedArticle(article)
}
const handleBack = () => {
setSelectedArticle(null)
}
const activeCategory = CATEGORIES.find(c => c.id === activeCategoryId)
return (
<Wrap className="flex flex-col gap-16 mt-20">
<h1>帮助中心和教程页面内容</h1>
</Wrap>
<div className="min-h-[calc(100vh-5rem)] bg-gray-50">
<Wrap className="pt-28 pb-16">
<div className="max-w-2xl mx-auto mb-10">
<div className="relative">
<SearchIcon className="absolute left-4 top-1/2 -translate-y-1/2 size-5 text-gray-400" />
<input
type="text"
placeholder="请输入想要了解的关键词"
className="w-full pl-12 pr-24 py-3.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm bg-white text-sm"
value={search}
onChange={e => {
setSearch(e.target.value)
setSelectedArticle(null)
}}
/>
<button className="absolute right-2 top-1/2 -translate-y-1/2 px-6 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition">
搜索
</button>
</div>
</div>
<div className="flex gap-8">
<aside className="w-52 shrink-0">
<nav className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden sticky top-28">
<div className="px-5 py-4 border-b border-gray-100">
<h3 className="font-semibold text-gray-800">帮助分类</h3>
</div>
<ul>
<li>
<button
onClick={() => {
setActiveCategoryId(null)
setSelectedArticle(null)
}}
className={cn(
"w-full text-left px-5 py-3 text-sm transition-colors flex items-center justify-between",
activeCategoryId === null
? "bg-blue-50 text-blue-600 font-medium"
: "text-gray-600 hover:bg-gray-50",
)}
>
全部文章
<ChevronRightIcon className="size-4" />
</button>
</li>
{CATEGORIES.map(cat => (
<li key={cat.id}>
<button
onClick={() => {
setActiveCategoryId(cat.id)
setSelectedArticle(null)
}}
className={cn(
"w-full text-left px-5 py-3 text-sm transition-colors flex items-center justify-between",
activeCategoryId === cat.id
? "bg-blue-50 text-blue-600 font-medium"
: "text-gray-600 hover:bg-gray-50",
)}
>
{cat.name}
<ChevronRightIcon className="size-4" />
</button>
</li>
))}
</ul>
</nav>
</aside>
<main className="flex-1 min-w-0">
{selectedArticle ? (
<ArticleDetail
article={selectedArticle}
prev={prevArticle}
next={nextArticle}
onPrev={
prevArticle
? () => handleArticleClick(prevArticle)
: undefined
}
onNext={
nextArticle
? () => handleArticleClick(nextArticle)
: undefined
}
onBack={handleBack}
categoryName={activeCategory?.name}
/>
) : (
<ArticleList
articles={filtered}
search={search}
categoryName={activeCategory?.name}
onArticleClick={handleArticleClick}
/>
)}
</main>
</div>
</Wrap>
</div>
)
}
function ArticleList({
articles,
search,
categoryName,
onArticleClick,
}: {
articles: Article[]
search: string
categoryName?: string
onArticleClick: (article: Article) => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<h2 className="text-lg font-semibold text-gray-800 mb-6">
{search
? `搜索"${search}"的结果`
: categoryName
? categoryName
: "全部文章"}
<span className="text-sm font-normal text-gray-400 ml-2">
共 {articles.length} 篇
</span>
</h2>
{articles.length === 0 ? (
<div className="text-center py-16 text-gray-400">
<SearchIcon className="size-12 mx-auto mb-4 opacity-30" />
<p>未找到相关文章,请尝试其他关键词</p>
</div>
) : (
<ul className="divide-y divide-gray-100">
{articles.map(article => (
<li key={article.id}>
<button
onClick={() => onArticleClick(article)}
className="w-full text-left py-4 px-2 -mx-2 rounded-lg hover:bg-gray-50 transition-colors group flex items-center justify-between"
>
<span className="text-sm text-gray-700 group-hover:text-blue-600 transition-colors pr-4">
{article.title}
</span>
<span className="text-xs text-gray-400 whitespace-nowrap shrink-0">
{article.createTime}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)
}
function ArticleDetail({
article,
prev,
next,
onPrev,
onNext,
onBack,
categoryName,
}: {
article: Article
prev: Article | null
next: Article | null
onPrev?: () => void
onNext?: () => void
onBack: () => void
categoryName?: string
}) {
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (contentRef.current) {
contentRef.current.innerHTML = article.content
}
}, [article.content])
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-8">
<nav className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<button
onClick={onBack}
className="hover:text-blue-600 transition-colors"
>
帮助中心
</button>
{categoryName && (
<>
<ChevronRightIcon className="size-3" />
<span>{categoryName}</span>
</>
)}
<ChevronRightIcon className="size-3" />
<span className="text-gray-600 truncate">{article.title}</span>
</nav>
<h1 className="text-xl font-bold text-gray-900 mb-3">{article.title}</h1>
<p className="text-xs text-gray-400 mb-8">{article.createTime}</p>
<div
ref={contentRef}
className="prose prose-sm max-w-none text-gray-700 leading-relaxed [&_h3]:text-lg [&_h3]:font-semibold [&_h3]:text-gray-800 [&_h3]:mt-6 [&_h3]:mb-3 [&_h4]:text-base [&_h4]:font-medium [&_h4]:text-gray-800 [&_h4]:mt-4 [&_h4]:mb-2 [&_p]:mb-3 [&_pre]:bg-gray-50 [&_pre]:p-4 [&_pre]:rounded-lg [&_pre]:text-sm [&_code]:bg-gray-100 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-sm [&_strong]:text-gray-800"
/>
<div className="mt-12 pt-6 border-t border-gray-100 flex items-center justify-between">
<div className="flex-1 min-w-0">
{prev ? (
<button
onClick={onPrev}
className="text-sm text-gray-500 hover:text-blue-600 transition-colors flex items-center gap-1 max-w-full"
>
<ArrowLeftIcon className="size-3.5 shrink-0" />
<span className="truncate">上一条:{prev.title}</span>
</button>
) : (
<span className="text-sm text-gray-300">已是第一篇</span>
)}
</div>
<div className="flex-1 min-w-0 text-right">
{next ? (
<button
onClick={onNext}
className="text-sm text-gray-500 hover:text-blue-600 transition-colors flex items-center gap-1 justify-end max-w-full"
>
<span className="truncate">下一条:{next.title}</span>
<ArrowRightIcon className="size-3.5 shrink-0" />
</button>
) : (
<span className="text-sm text-gray-300">已是最后一篇</span>
)}
</div>
</div>
<div className="mt-6 text-center">
<button
onClick={onBack}
className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-700 transition-colors"
>
<ArrowLeftIcon className="size-3.5" />
返回列表
</button>
</div>
</div>
)
}

View File

@@ -1,5 +1,8 @@
import { EyeIcon, EyeOffIcon } from "lucide-react"
import { useEffect, useState } from "react"
import { useLocation, useNavigate } from "react-router-dom"
import { Link, useLocation, useNavigate } from "react-router-dom"
import { toast } from "sonner"
import { login } from "@/api/auth"
import { Button } from "@/components/ui/button"
import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
@@ -21,9 +24,9 @@ export default function LoginPage() {
return (
<main
className={cn(
`relative`,
`h-screen w-screen xl:pr-64 bg-cover bg-left`,
`flex justify-center xl:justify-end items-center`,
"relative",
"h-screen w-screen xl:pr-64 bg-cover bg-left",
"flex justify-center xl:justify-end items-center",
)}
style={{ backgroundImage: `url(${bg})` }}
>
@@ -53,7 +56,11 @@ export default function LoginPage() {
注册
</Button>
</div>
{tab === "login" ? <LoginForm /> : <RegisterForm />}
{tab === "login" ? (
<LoginForm />
) : (
<RegisterForm onSwitchToLogin={() => setTab("login")} />
)}
</CardContent>
</Card>
</main>
@@ -62,11 +69,43 @@ export default function LoginPage() {
function LoginForm() {
const navigate = useNavigate()
const [account, setAccount] = useState("")
const [password, setPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [remember, setRemember] = useState(false)
const [submitting, setSubmitting] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
navigate("/admin")
if (!account.trim()) {
toast.error("请输入账号")
return
}
if (!password) {
toast.error("请输入密码")
return
}
setSubmitting(true)
try {
const resp = await login({
username: account,
password,
remember,
mode: "password",
})
if (resp.success) {
toast.success("登录成功")
navigate("/admin")
} else {
toast.error(resp.message || "登录失败")
}
} catch {
toast.error("网络错误,请重试")
} finally {
setSubmitting(false)
}
}
return (
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
@@ -78,44 +117,166 @@ function LoginForm() {
type="text"
placeholder="请输入淘宝名/会员手机号码"
className="w-full"
value={account}
onChange={e => setAccount(e.target.value)}
/>
</div>
<div className="space-y-1">
<label htmlFor="password" className="text-sm font-medium text-gray-700">
密码
</label>
<Input
id="password"
type="password"
placeholder="请输入密码(8-12位数字或字母组合)"
className="w-full"
/>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="请输入密码(8-12位数字或字母组合)"
className="w-full pr-10"
value={password}
onChange={e => setPassword(e.target.value)}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="size-4" />
) : (
<EyeIcon className="size-4" />
)}
</button>
</div>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center space-x-2">
<Checkbox id="remember" />
<Checkbox
id="remember"
checked={remember}
onCheckedChange={v => setRemember(!!v)}
/>
<label htmlFor="remember" className="text-gray-600 cursor-pointer">
记住密码
</label>
</div>
<span className="text-gray-400 text-xs">
手机号不是IP账号,请登陆后开通IP账号
</span>
<Link
to="/login"
state={{ tab: "register" }}
className="text-blue-600 hover:text-blue-700 text-xs"
>
忘记密码?
</Link>
</div>
<Button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
disabled={submitting}
>
登录
{submitting ? "登录中..." : "登录"}
</Button>
<p className="text-center text-xs text-gray-400 mt-2">首次登录即注册</p>
<p className="text-center text-xs text-gray-400 mt-2">
手机号不是IP账号,请登陆后开通IP账号
</p>
</form>
)
}
function RegisterForm() {
function RegisterForm({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
const navigate = useNavigate()
const [phone, setPhone] = useState("")
const [smsCode, setSmsCode] = useState("")
const [smsCountdown, setSmsCountdown] = useState(0)
const [smsLoading, setSmsLoading] = useState(false)
const [newPassword, setNewPassword] = useState("")
const [showPassword, setShowPassword] = useState(false)
const [wx, setWx] = useState("")
const [qq, setQq] = useState("")
const [agreed, setAgreed] = useState(false)
const [submitting, setSubmitting] = useState(false)
const handleSendSMS = async () => {
if (!/^1\d{10}$/.test(phone)) {
toast.error("请输入正确的手机号")
return
}
setSmsLoading(true)
try {
const { sendSMS } = await import("@/api/verify")
const resp = await sendSMS(phone, "User_Code")
if (!resp.success) {
throw new Error(resp.message || "短信发送失败")
}
let countdown = 60
setSmsCountdown(countdown)
const timer = setInterval(() => {
countdown -= 1
if (countdown <= 0) {
clearInterval(timer)
setSmsCountdown(0)
} else {
setSmsCountdown(countdown)
}
}, 1000)
toast.success("验证码已发送")
} catch (e) {
toast.error("验证码发送失败", {
description: (e as Error).message,
})
} finally {
setSmsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!/^1\d{10}$/.test(phone)) {
toast.error("请输入正确的手机号")
return
}
if (smsCode.length < 4 || smsCode.length > 6) {
toast.error("请输入短信验证码")
return
}
if (!/^[a-zA-Z0-9]{8,12}$/.test(newPassword)) {
toast.error("密码为8-12位数字或字母组合")
return
}
if (!agreed) {
toast.error("请先阅读并同意用户协议和隐私政策")
return
}
setSubmitting(true)
try {
const resp = await login({
username: phone,
password: newPassword,
code: smsCode,
remember: false,
mode: "phone_code",
wx,
qq,
})
if (resp.success) {
toast.success("注册成功")
navigate("/admin")
} else {
toast.error(resp.message || "注册失败")
}
} catch {
toast.error("网络错误,请重试")
} finally {
setSubmitting(false)
}
}
return (
<form className="space-y-4">
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="space-y-1">
<label
htmlFor="reg-account"
@@ -128,44 +289,135 @@ function RegisterForm() {
type="text"
placeholder="请输入手机号码"
className="w-full"
value={phone}
onChange={e => setPhone(e.target.value)}
/>
</div>
<div className="space-y-1">
<label htmlFor="reg-sms" className="text-sm font-medium text-gray-700">
短信验证码
</label>
<div className="flex gap-2">
<Input
id="reg-sms"
type="text"
placeholder="请输入短信验证码"
className="h-10"
maxLength={6}
autoComplete="one-time-code"
value={smsCode}
onChange={e => setSmsCode(e.target.value.replace(/\D/g, ""))}
/>
<Button
type="button"
variant="outline"
className="h-10 w-28 shrink-0 whitespace-nowrap"
onClick={handleSendSMS}
disabled={smsCountdown > 0 || smsLoading}
>
{smsLoading
? "发送中..."
: smsCountdown > 0
? `${smsCountdown}秒后重发`
: "获取验证码"}
</Button>
</div>
</div>
<div className="space-y-1">
<label
htmlFor="reg-password"
className="text-sm font-medium text-gray-700"
>
设置密码
请输入密码
</label>
<div className="relative">
<Input
id="reg-password"
type={showPassword ? "text" : "password"}
placeholder="请输入8-12位数字或字母组合"
className="w-full pr-10"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeIcon className="size-4" />
) : (
<EyeOffIcon className="size-4" />
)}
</button>
</div>
</div>
<div className="space-y-1">
<label htmlFor="reg-wx" className="text-sm font-medium text-gray-700">
微信号
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
</label>
<Input
id="reg-password"
type="password"
placeholder="8-12位数字或字母组合"
id="reg-wx"
type="text"
placeholder="请输入微信号"
className="w-full"
value={wx}
onChange={e => setWx(e.target.value)}
/>
</div>
<div className="space-y-1">
<label
htmlFor="reg-confirm"
className="text-sm font-medium text-gray-700"
>
确认密码
<label htmlFor="reg-qq" className="text-sm font-medium text-gray-700">
QQ号
<span className="text-gray-400 font-normal text-xs ml-1">(选填)</span>
</label>
<Input
id="reg-confirm"
type="password"
placeholder="再次输入密码"
id="reg-qq"
type="text"
placeholder="请输入QQ号"
className="w-full"
value={qq}
onChange={e => setQq(e.target.value.replace(/\D/g, ""))}
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="agreement"
checked={agreed}
onCheckedChange={v => setAgreed(!!v)}
/>
<label
htmlFor="agreement"
className="text-xs text-gray-600 cursor-pointer"
>
我同意
<a
href="/xieyi.html"
className="text-blue-600 hover:text-blue-500"
target="_blank"
rel="noopener"
>
《聚IP JUIP.COM用户注册协议》
</a>
</label>
</div>
<Button
type="submit"
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
disabled={submitting}
>
注册
{submitting ? "提交中..." : "首次登录即注册"}
</Button>
<p className="text-center text-xs text-gray-400 mt-2">
注册即代表同意《用户协议》和《隐私政策》
已有账号?
<button
type="button"
className="text-blue-600 hover:text-blue-700 ml-1"
onClick={onSwitchToLogin}
>
立即登录
</button>
</p>
</form>
)

View File

@@ -8,11 +8,20 @@ import ProductSidebar from "./productSidebar"
import ProductTabs from "./productTabs"
import PurchaseInfo from "./purchaseInfo"
const TAB_ALIAS: Record<string, string> = {
short: "dynamic",
long: "dynamic",
global: "dynamic",
tunnel: "dynamic",
exclusive: "static",
}
export default function ProductPage() {
const [searchParams, setSearchParams] = useSearchParams()
const [activeVersionId, setActiveVersionId] = useState<string>("v1")
const activeTabId = searchParams.get("tab") || "dynamic"
const urlTab = searchParams.get("tab") || "dynamic"
const activeTabId = TAB_ALIAS[urlTab] || urlTab
const activeBrandId = searchParams.get("brand") || ""
const currentTab =

186
src/lib/models/article.ts Normal file
View File

@@ -0,0 +1,186 @@
export type ArticleCategory = {
id: number
name: string
}
export type Article = {
id: number
title: string
subTitle: string
content: string
catalogId: number
createTime: string
keyword: string
}
export const CATEGORIES: ArticleCategory[] = [
{ id: 5, name: "公告中心" },
{ id: 4, name: "直连教程" },
{ id: 3, name: "常见售后" },
{ id: 1, name: "其他帮助" },
{ id: 2, name: "优惠活动" },
{ id: 6, name: "代理合作" },
]
export const ARTICLES: Article[] = [
{
id: 1,
title: "聚IP代理使用协议",
subTitle: "",
content:
"<h3>聚IP代理使用协议</h3><p>感谢您选择聚IP代理服务。在使用本服务前,请您仔细阅读以下条款...</p><p>1. 服务说明:聚IP提供动态和静态代理IP服务,支持HTTP/HTTPS/SOCKS5协议。</p><p>2. 使用规范:禁止用于任何违法违规行为,包括但不限于网络攻击、欺诈、侵权等。</p><p>3. 账号管理:请妥善保管账号密码,因账号泄露造成的损失由用户自行承担。</p>",
catalogId: 5,
createTime: "2025-06-01",
keyword: "使用协议,服务条款",
},
{
id: 2,
title: "系统升级维护通知",
subTitle: "",
content:
"<h3>系统升级维护通知</h3><p>尊敬的用户:</p><p>为提升服务质量,聚IP将于2025年7月15日凌晨2:00-6:00进行系统升级维护,届时部分服务可能短暂中断,请您提前做好安排。给您带来的不便敬请谅解。</p>",
catalogId: 5,
createTime: "2025-07-10",
keyword: "维护通知,系统升级",
},
{
id: 3,
title: "苹果手机直连IP设置教程",
subTitle: "",
content:
'<h3>苹果手机直连IP设置教程</h3><p>适用设备:iPhone / iPad(iOS 12.0及以上)</p><h4>步骤一:获取代理信息</h4><p>登录聚IP官网,购买并获取代理IP地址、端口号、账号和密码。</p><h4>步骤二:配置WiFi代理</h4><p>1. 打开"设置" → "无线局域网"<br/>2. 点击当前连接的WiFi名称旁的 (i) 图标<br/>3. 滚动到底部,点击"配置代理"<br/>4. 选择"手动"<br/>5. 输入服务器地址和端口<br/>6. 如果需要认证,开启"认证"并输入用户名和密码</p><h4>步骤三:验证连接</h4><p>打开Safari浏览器访问 ipinfo.io 查看当前IP是否已切换。</p>',
catalogId: 4,
createTime: "2025-05-20",
keyword: "苹果,iPhone,iOS,代理设置",
},
{
id: 4,
title: "安卓手机代理设置教程",
subTitle: "",
content:
'<h3>安卓手机代理设置教程</h3><p>适用设备:Android 8.0及以上</p><h4>步骤一:获取代理信息</h4><p>登录聚IP官网,购买并获取代理IP地址和端口号。</p><h4>步骤二:配置WiFi代理</h4><p>1. 打开"设置" → "WLAN"<br/>2. 长按当前连接的WiFi名称<br/>3. 选择"修改网络" → "高级选项"<br/>4. 代理选择"手动"<br/>5. 输入代理服务器主机名和端口<br/>6. 保存设置</p><h4>步骤三:验证</h4><p>打开浏览器访问 ipinfo.io 验证IP是否已变更。</p>',
catalogId: 4,
createTime: "2025-05-20",
keyword: "安卓,Android,代理设置",
},
{
id: 5,
title: "Windows电脑代理设置教程",
subTitle: "",
content:
'<h3>Windows电脑代理设置教程</h3><p>适用系统:Windows 10 / Windows 11</p><h4>方法一:系统代理设置</h4><p>1. 打开"设置" → "网络和Internet" → "代理"<br/>2. 在"手动设置代理"中,开启"使用代理服务器"<br/>3. 输入代理IP地址和端口<br/>4. 点击"保存"</p><h4>方法二:使用客户端</h4><p>推荐下载聚IP客户端,一键切换IP,支持定时切换、API调用等高级功能。</p>',
catalogId: 4,
createTime: "2025-05-18",
keyword: "Windows,电脑,代理设置",
},
{
id: 6,
title: "浏览器代理插件使用教程",
subTitle: "",
content:
'<h3>浏览器代理插件使用教程</h3><p>推荐使用 SwitchyOmega 插件管理代理。</p><h4>Chrome/Edge 安装步骤</h4><p>1. 打开Chrome应用商店,搜索"SwitchyOmega"<br/>2. 点击"添加至Chrome"安装<br/>3. 点击插件图标 → "选项"<br/>4. 新建情景模式,选择"代理服务器"<br/>5. 输入代理协议、IP地址和端口<br/>6. 保存并应用</p>',
catalogId: 4,
createTime: "2025-05-15",
keyword: "浏览器,插件,SwitchyOmega",
},
{
id: 7,
title: "代理连接失败怎么办?",
subTitle: "",
content:
"<h3>代理连接失败排查指南</h3><p>如果遇到代理连接失败,请按以下步骤排查:</p><p><strong>1. 检查账号是否过期</strong><br/>登录官网查看IP账号到期时间,过期请续费。</p><p><strong>2. 检查IP白名单</strong><br/>确保当前设备的公网IP已添加到白名单中。</p><p><strong>3. 检查网络连通性</strong><br/>使用 ping 或 telnet 测试代理服务器是否可达。</p><p><strong>4. 切换节点</strong><br/>尝试更换其他线路或地区的IP节点。</p><p>如仍无法解决,请联系在线客服。</p>",
catalogId: 3,
createTime: "2025-04-25",
keyword: "连接失败,排查,故障",
},
{
id: 8,
title: "IP白名单添加与修改",
subTitle: "",
content:
'<h3>IP白名单添加与修改</h3><p>白名单用于授权特定IP地址访问代理服务。</p><p><strong>添加白名单:</strong><br/>1. 登录聚IP官网 → 进入用户中心<br/>2. 找到对应IP账号 → 点击"白名单管理"<br/>3. 输入需要授权的IP地址 → 保存</p><p><strong>注意事项:</strong><br/>- 每次最多可添加5个白名单IP<br/>- 修改后约1-3分钟生效<br/>- 请确保填写正确的公网IP地址</p>',
catalogId: 3,
createTime: "2025-04-20",
keyword: "白名单,IP授权",
},
{
id: 9,
title: "如何查询剩余流量和到期时间",
subTitle: "",
content:
'<h3>如何查询剩余流量和到期时间</h3><p>1. 登录聚IP官网,进入"用户中心"</p><p>2. 在"我的IP账号"列表中可以看到:<br/>- 到期时间<br/>- 剩余流量<br/>- 连接数上限<br/>- 当前在线状态</p><p>3. 即将到期时,系统会通过短信提醒,请及时续费避免服务中断。</p>',
catalogId: 3,
createTime: "2025-04-15",
keyword: "流量,到期时间,查询",
},
{
id: 10,
title: "如何通过API获取代理IP",
subTitle: "",
content:
'<h3>API获取代理IP教程</h3><p>聚IP支持通过API接口获取代理IP,方便程序化调用。</p><h4>API地址</h4><p><code>GET https://api.juip.com/v1/proxy/ip</code></p><h4>请求参数</h4><p>- apikey: 用户API密钥(在用户中心获取)<br/>- num: 获取IP数量<br/>- type: IP类型(1=HTTP, 2=SOCKS5)<br/>- area: 地区编码</p><h4>返回示例</h4><pre>{"code":10000,"data":[{"ip":"1.2.3.4","port":8080}]}</pre>',
catalogId: 4,
createTime: "2025-05-01",
keyword: "API,接口,程序调用",
},
{
id: 11,
title: "618优惠活动说明",
subTitle: "",
content:
"<h3>618优惠活动</h3><p>聚IP年中大促,全场代理套餐低至5折!</p><p>活动时间:6月1日 - 6月20日</p><p><strong>活动内容:</strong><br/>1. 所有动态IP套餐享受8折优惠<br/>2. 静态IP包年套餐直降200元<br/>3. 新用户注册即送3次免费测试机会<br/>4. 老用户续费额外赠送7天时长</p><p>数量有限,先到先得!</p>",
catalogId: 2,
createTime: "2025-06-01",
keyword: "618,优惠,促销",
},
{
id: 12,
title: "代理合作推广计划",
subTitle: "",
content:
"<h3>代理合作推广计划</h3><p>聚IP诚邀各类合作伙伴加入推广计划,共享收益!</p><p><strong>合作模式:</strong><br/>1. CPS分成:每成功推荐一位付费用户,获得30%佣金<br/>2. API代理:将聚IP代理能力集成到您的产品中<br/>3. 流量合作:大批量采购享受专属折扣</p><p><strong>合作要求:</strong><br/>- 拥有网站、公众号或社群资源<br/>- 遵守相关法律法规<br/>- 不接受涉及违法违规业务的合作</p><p>有意合作请联系在线客服或发送邮件至 business@juip.com</p>",
catalogId: 6,
createTime: "2025-05-10",
keyword: "合作,代理加盟,推广",
},
{
id: 13,
title: "国庆特惠活动",
subTitle: "",
content:
"<h3>国庆特惠活动</h3><p>庆祝国庆,聚IP推出限时特惠!</p><p>活动时间:10月1日 - 10月7日</p><p><strong>活动详情:</strong><br/>1. 全场套餐9折优惠<br/>2. 充值满100减10,满200减30<br/>3. 购买年套餐额外赠送1个月</p>",
catalogId: 2,
createTime: "2025-09-28",
keyword: "国庆,特惠,活动",
},
{
id: 14,
title: "模拟器代理配置教程",
subTitle: "",
content:
'<h3>模拟器代理配置教程</h3><p>适用:雷电模拟器、夜神模拟器、MuMu模拟器等</p><h4>方法一:模拟器内WiFi代理</h4><p>1. 打开模拟器 → 进入"设置" → "WLAN"<br/>2. 长按当前WiFi → "修改网络"<br/>3. 设置代理为手动,填写IP和端口</p><h4>方法二:使用代理APP</h4><p>下载Postern或ProxyDroid等代理APP,在模拟器中安装并配置代理。</p>',
catalogId: 4,
createTime: "2025-05-22",
keyword: "模拟器,代理,雷电,夜神",
},
{
id: 15,
title: "软路由代理配置教程",
subTitle: "",
content:
"<h3>软路由代理配置教程</h3><p>适用:OpenWrt / LEDE / iKuai 等软路由系统</p><h4>OpenWrt配置步骤</h4><p>1. SSH登录路由器<br/>2. 安装代理客户端软件<br/>3. 配置代理服务器地址和认证信息<br/>4. 设置流量转发规则<br/>5. 重启网络服务生效</p><p>详细配置脚本请联系技术支持获取。</p>",
catalogId: 4,
createTime: "2025-05-25",
keyword: "软路由,OpenWrt,代理配置",
},
{
id: 16,
title: "如何联系客服",
subTitle: "",
content:
"<h3>如何联系客服</h3><p>我们提供多种客服渠道:</p><p><strong>在线客服:</strong><br/>工作时间:8:00 - 23:30<br/>点击官网右下角客服图标即可在线咨询。</p><p><strong>QQ客服:</strong><br/>客服QQ:123456789</p><p><strong>邮件支持:</strong><br/>support@juip.com</p><p><strong>常见问题:</strong><br/>建议先查看帮助中心,大部分问题已有解答。</p>",
catalogId: 1,
createTime: "2025-04-01",
keyword: "客服,联系,帮助",
},
]

View File

@@ -3,6 +3,8 @@ import Channels from "@/admin/channels/page"
import Dashboard from "@/admin/dashboard/page"
import Funds from "@/admin/funds/page"
import AdminLayout from "@/admin/layout"
import LongPage from "@/admin/product/long"
import ShortPage from "@/admin/product/short"
import Whitelist from "@/admin/whitelist/page"
import AuthGuard from "@/components/authGuard"
import HomePage from "@/home"
@@ -42,9 +44,13 @@ export const router = createBrowserRouter([
{ path: "channels", element: <Channels /> },
{ path: "whitelist", element: <Whitelist /> },
{ path: "funds", element: <Funds /> },
{ path: "Channels", element: <Channels /> },
{ path: "Channels", element: <Channels /> },
{ path: "Channels", element: <Channels /> },
{
path: "product",
children: [
{ path: "long", element: <LongPage /> },
{ path: "short", element: <ShortPage /> },
],
},
],
},
])