312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
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 ?? []))
|
||
}
|
||
void 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 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">
|
||
<ul>
|
||
{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 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"
|
||
>
|
||
<<<下载客户端>>>
|
||
</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-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-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
|
||
height={500}
|
||
src={videoUrl}
|
||
className="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 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 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="flex-1 min-w-0 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>
|
||
)
|
||
}
|