Files
juip-web/src/home/help/index.tsx
2026-09-11 17:06:29 +08:00

311 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeftIcon,
ArrowRightIcon,
ChevronRightIcon,
SearchIcon,
} from "lucide-react"
import { useEffect, useRef, useState } from "react"
import { Link } from "react-router-dom"
import { Toaster, toast } from "sonner"
import { fetchArticles } from "@/api/article"
import Wrap from "@/components/wrap"
import {
type Article,
CATEGORIES,
transformArticles,
} from "@/lib/models/article"
import { cn } from "@/lib/utils"
const VIDEO_TUTORIALS: Record<number, string> = {
3: "http://mp4.juip.com/%E8%8B%B9%E6%9E%9C%E6%89%8B%E6%9C%BA%E6%95%99%E7%A8%8B.mp4",
4: "http://mp4.juip.com/%E5%AE%89%E5%8D%93%E6%89%8B%E6%9C%BA%E6%95%99%E7%A8%8B.mp4",
5: "http://mp4.juip.com/%E6%A8%A1%E6%8B%9F%E5%99%A8%E6%95%99%E7%A8%8B.mp4",
56: "http://mp4.juip.com/wjjc.mp4",
}
export default function HelpPage() {
const [search, setSearch] = useState("")
const [keyword, setKeyword] = useState("")
const [activeCategoryId, setActiveCategoryId] = useState<number | null>(null)
const [selectedArticle, setSelectedArticle] = useState<Article | null>(null)
const [articles, setArticles] = useState<Article[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
const load = async () => {
const res = await fetchArticles({
catalog: activeCategoryId ?? undefined,
keyword,
})
if (cancelled) return
setLoading(false)
if (!res.success) {
setArticles([])
toast.error(res.message)
return
}
setArticles(transformArticles(res.data.List ?? []))
}
load()
return () => {
cancelled = true
}
}, [activeCategoryId, keyword])
const handleSearch = () => {
setKeyword(search.trim())
setSelectedArticle(null)
}
const currentIndex = selectedArticle
? articles.findIndex(a => a.id === selectedArticle.id)
: -1
const prevArticle = currentIndex > 0 ? articles[currentIndex - 1] : null
const nextArticle =
currentIndex >= 0 && currentIndex < articles.length - 1
? articles[currentIndex + 1]
: null
const handleArticleClick = (article: Article) => {
setSelectedArticle(article)
}
const handleBack = () => {
setSelectedArticle(null)
}
const activeCategory = CATEGORIES.find(c => c.id === activeCategoryId)
return (
<div className="min-h-[calc(100vh-5rem)] bg-gray-50">
<Toaster position="top-center" richColors />
<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)
}}
onKeyDown={e => {
if (e.key === "Enter") handleSearch()
}}
/>
<button
onClick={handleSearch}
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 cursor-pointer"
>
搜索
</button>
</div>
</div>
<div className="flex flex-col md:flex-row gap-6 md:gap-8">
<aside className="w-full md:w-52 md:shrink-0">
<nav className="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden md:sticky md:top-28">
<ul className="flex overflow-x-auto scrollbar-hide md:block">
{CATEGORIES.map(cat => (
<li key={cat.id} className="shrink-0">
<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 whitespace-nowrap",
activeCategoryId === cat.id
? "bg-blue-50 text-blue-600 font-medium cursor-pointer"
: "text-gray-600 hover:bg-gray-50 cursor-pointer",
)}
>
{cat.name}
<ChevronRightIcon className="size-4" />
</button>
</li>
))}
</ul>
</nav>
</aside>
<main className="flex-1 min-w-0">
{activeCategoryId === 4 && !selectedArticle && (
<div className="bg-blue-50/70 border border-blue-100 rounded-xl p-3 mb-4 text-sm text-gray-600 leading-relaxed">
<p>
*请优先选择客户端连接
<Link
to="/softdownload"
className="text-blue-600 hover:underline font-medium"
>
&lt;&lt;&lt;下载客户端&gt;&gt;&gt;
</Link>
</p>
<p>*无对应客户端时,可通过线路表直连支持所有设备</p>
</div>
)}
{loading ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-16 text-center text-gray-400">
加载中...
</div>
) : 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={articles}
onArticleClick={handleArticleClick}
/>
)}
</main>
</div>
</Wrap>
</div>
)
}
function ArticleList({
articles,
onArticleClick,
}: {
articles: Article[]
onArticleClick: (article: Article) => void
}) {
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-100 p-4 md:p-8">
{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 cursor-pointer justify-between "
>
<span className="text-sm text-gray-700 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)
const videoUrl = VIDEO_TUTORIALS[article.id]
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-4 md:p-8">
<nav className="flex items-center gap-2 text-sm text-gray-400 mb-6">
{categoryName && (
<>
<button onClick={onBack} className="text-gray-400 cursor-pointer">
{categoryName}
</button>
<ChevronRightIcon className="size-3" />
</>
)}
<span className="text-gray-600 truncate">{article.title}</span>
</nav>
{videoUrl && (
<div className="text-center mb-6">
<h1 className="text-lg font-bold text-gray-800 mb-3">视频教程</h1>
{/* biome-ignore lint/a11y/useMediaCaption: 教程视频无字幕文件 */}
<video
controls
src={videoUrl}
className="w-full h-auto max-w-full mx-auto rounded-lg"
/>
</div>
)}
<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 flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
{prev ? (
<button
onClick={onPrev}
className="text-sm text-gray-500 hover:text-blue-600 transition-colors cursor-pointer 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="min-w-0 md:text-right">
{next ? (
<button
onClick={onNext}
className="text-sm text-gray-500 hover:text-blue-600 transition-colors cursor-pointer 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>
)
}