更新登录页面和帮助教程
This commit is contained in:
@@ -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"
|
||||
>
|
||||
业务定制 →
|
||||
|
||||
3
src/admin/product/long.tsx
Normal file
3
src/admin/product/long.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function LongPage() {
|
||||
return <div>LongPage</div>
|
||||
}
|
||||
3
src/admin/product/short.tsx
Normal file
3
src/admin/product/short.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function ShortPage() {
|
||||
return <div>ShortPage</div>
|
||||
}
|
||||
@@ -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
15
src/api/verify.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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}</>
|
||||
}
|
||||
|
||||
BIN
src/docs/android-proxy/images/android.webp
Normal file
BIN
src/docs/android-proxy/images/android.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
src/docs/android-proxy/images/android01.webp
Normal file
BIN
src/docs/android-proxy/images/android01.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
src/docs/android-proxy/images/android02.webp
Normal file
BIN
src/docs/android-proxy/images/android02.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
30
src/docs/android-proxy/page.mdx
Normal file
30
src/docs/android-proxy/page.mdx
Normal 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一致,则代理设置成功
|
||||
|
||||
|
||||
BIN
src/docs/browser-proxy/images/ieConfig.webp
Normal file
BIN
src/docs/browser-proxy/images/ieConfig.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src/docs/browser-proxy/images/lanConfig.webp
Normal file
BIN
src/docs/browser-proxy/images/lanConfig.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
25
src/docs/browser-proxy/page.mdx
Normal file
25
src/docs/browser-proxy/page.mdx
Normal 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地址及端口。点击确定,即设置成功了。
|
||||
@@ -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"
|
||||
>
|
||||
业务定制 →
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
186
src/lib/models/article.ts
Normal 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: "客服,联系,帮助",
|
||||
},
|
||||
]
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user