Files
juip-web/src/home/help/index.tsx
2026-08-24 15:23:23 +08:00

312 lines
10 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 ?? []))
}
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"
>
&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-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>
)
}