diff --git a/bun.lock b/bun.lock index b99ce5c..023a1d2 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "lucide-react": "^0.479.0", "next": "^16.0.10", "next-themes": "^0.4.6", + "photoswipe": "^5.4.4", "qrcode": "^1.5.4", "react": "^19.2.1", "react-day-picker": "8.10.1", @@ -1164,6 +1165,8 @@ "path-parse": ["path-parse@1.0.7", "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "photoswipe": ["photoswipe@5.4.4", "https://registry.npmmirror.com/photoswipe/-/photoswipe-5.4.4.tgz", {}, "sha512-WNFHoKrkZNnvFFhbHL93WDkW3ifwVOXSW3w1UuZZelSmgXpIGiZSNlZJq37rR8YejqME2rHs9EhH9ZvlvFH2NA=="], + "picocolors": ["picocolors@1.1.1", "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], diff --git a/package.json b/package.json index b5fe71d..a149837 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "lucide-react": "^0.479.0", "next": "^16.0.10", "next-themes": "^0.4.6", + "photoswipe": "^5.4.4", "qrcode": "^1.5.4", "react": "^19.2.1", "react-day-picker": "8.10.1", diff --git a/src/actions/article.ts b/src/actions/article.ts new file mode 100644 index 0000000..462aa32 --- /dev/null +++ b/src/actions/article.ts @@ -0,0 +1,12 @@ +'use server' + +import {callByDevice} from './base' +import {ArticleDetail, ArticleNavGroup} from '@/lib/models/article' + +export async function getArticleNav(params: {}) { + return await callByDevice('/api/article/nav', params) +} + +export async function getArticleDetail(params: {id: number}) { + return await callByDevice('/api/article/get', params) +} diff --git a/src/app/(home)/docs/[groupCode]/[articleId]/article-viewer.tsx b/src/app/(home)/docs/[groupCode]/[articleId]/article-viewer.tsx new file mode 100644 index 0000000..6eba217 --- /dev/null +++ b/src/app/(home)/docs/[groupCode]/[articleId]/article-viewer.tsx @@ -0,0 +1,59 @@ +'use client' + +import {useEffect, useRef} from 'react' +import 'photoswipe/style.css' + +export default function ArticleViewer({content}: {content: string}) { + const containerRef = useRef(null) + + useEffect(() => { + const container = containerRef.current + if (!container) return + + const handleClick = async (e: MouseEvent) => { + const target = e.target as HTMLElement + if (target.tagName !== 'IMG') return + if (!container.contains(target)) return + + const allImages = Array.from(container.querySelectorAll('img')) + const slides: Array<{src: string, width: number, height: number}> = [] + let clickedIndex = 0 + + allImages.forEach((img) => { + const src = img.getAttribute('src') + if (!src) return + if (img === target) clickedIndex = slides.length + slides.push({ + src, + width: img.naturalWidth || 1600, + height: img.naturalHeight || 1200, + }) + }) + + if (slides.length === 0) return + + const {default: PhotoSwipe} = await import('photoswipe') + const pswp = new PhotoSwipe({ + dataSource: slides, + index: clickedIndex, + bgOpacity: 0.85, + spacing: 0.12, + zoom: true, + }) + pswp.init() + } + + container.addEventListener('click', handleClick) + return () => { + container.removeEventListener('click', handleClick) + } + }, [content]) + + return ( +
+ ) +} diff --git a/src/app/(home)/docs/[groupCode]/[articleId]/page.tsx b/src/app/(home)/docs/[groupCode]/[articleId]/page.tsx new file mode 100644 index 0000000..975e054 --- /dev/null +++ b/src/app/(home)/docs/[groupCode]/[articleId]/page.tsx @@ -0,0 +1,60 @@ +import {notFound} from 'next/navigation' +import {Suspense} from 'react' +import {getArticleDetail} from '@/actions/article' +import {formatDate} from '@/lib/utils/date' +import ArticleViewer from './article-viewer' + +interface ArticlePageProps { + params: Promise<{groupCode: string, articleId: string}> +} + +function ArticleLoadingSkeleton() { + return ( +
+
+
+
+
+
+ {[1, 2, 3, 4].map(i => ( +
+ ))} +
+
+ ) +} + +async function ArticleContent(props: ArticlePageProps) { + const params = await props.params + + const resp = await getArticleDetail({id: Number(params.articleId)}) + + if (!resp.success || !resp.data) { + notFound() + } + + const article = resp.data + + return ( +
+
+

+ {article.title} +

+
+ 更新日期:{formatDate(article.updated_at, 'YYYY-MM-DD HH:mm:ss')} +
+
+ + +
+ ) +} + +export default function ArticlePage(props: ArticlePageProps) { + return ( + }> + + + ) +} diff --git a/src/app/(home)/docs/layout.tsx b/src/app/(home)/docs/layout.tsx index 1dc644a..021ae58 100644 --- a/src/app/(home)/docs/layout.tsx +++ b/src/app/(home)/docs/layout.tsx @@ -3,13 +3,14 @@ import {Children} from '@/lib/utils' import Sidebar from './sidebar' import HomePage from '@/components/home/page' import SidebarDrawer from './sidebar-drawer' +import {Suspense} from 'react' export default function DocsLayout(props: Children) { return ( - +
{props.children}
diff --git a/src/app/(home)/docs/sidebar.tsx b/src/app/(home)/docs/sidebar.tsx index 7106abf..a040ee5 100644 --- a/src/app/(home)/docs/sidebar.tsx +++ b/src/app/(home)/docs/sidebar.tsx @@ -1,59 +1,11 @@ 'use client' -import {useState, useMemo, useCallback} from 'react' +import {useState, useEffect, useMemo} from 'react' import Link from 'next/link' import {usePathname} from 'next/navigation' import {ChevronRight} from 'lucide-react' import {merge} from '@/lib/utils' - -// 菜单配置 -const MENU_ITEMS = [ - { - group: '产品文档', - items: [ - {key: 'product-overview', label: '产品介绍'}, - {key: 'choose-product', label: '如何选择产品'}, - {key: 'why-verify', label: '为什么需要实名认证'}, - {key: 'city-lines', label: '有哪些城市线路'}, - {key: 'api-docs', label: 'ip提取接口文档'}, - // 服务条款 - ], - }, - { - group: '操作指南', - items: [ - {key: 'profile-settings', label: '修改个人信息和重置密码'}, - {key: 'whitelist-guide', label: '如何添加白名单'}, - {key: 'verify-guide', label: '如何进行实名认证'}, - {key: 'extract-link', label: '如何生成提取链接'}, - {key: 'payment-records', label: '查看支付和使用记录'}, - ], - }, - { - group: '客户端教程', - items: [ - {key: 'browser-proxy', label: '浏览器设置代理教程'}, - {key: 'ios-proxy', label: 'iOS设置代理教程'}, - {key: 'android-proxy', label: '安卓手机设置代理教程'}, - {key: 'windows10-proxy', label: 'Windows10设置代理教程'}, - ], - }, - { - group: '常见问题', - items: [ - {key: 'faq-general', label: '常见问题总览'}, - {key: 'faq-billing', label: '计费与套餐问题'}, - // 业务场景集成方案 - // 故障排查 - ], - }, - { - group: '新闻资讯', - items: [ - {key: 'news-latest', label: '了解代理服务器的工作原理'}, - {key: 'news-announce', label: '网站公告'}, - ], - }, -] +import {getArticleNav} from '@/actions/article' +import type {ArticleNavGroup} from '@/lib/models/article' type Props = { className?: string @@ -62,88 +14,126 @@ type Props = { export default function Sidebar({className, onClose}: Props) { const pathname = usePathname() + const [navGroups, setNavGroups] = useState([]) + const [loading, setLoading] = useState(true) + const [manualExpanded, setManualExpanded] = useState>({}) - // 获取当前文档 key - const getCurrentKey = useCallback(() => { - const parts = pathname?.split('/') || [] - return parts[2] || '' - }, [pathname]) + useEffect(() => { + const loadNav = async () => { + const resp = await getArticleNav({}) + if (resp.success) { + setNavGroups(resp.data || []) + } + setLoading(false) + } + loadNav() + }, []) - const currentKey = getCurrentKey() + const parts = pathname?.split('/') || [] + const currentArticleId = parts[3] - // 展开/收起状态 - const [expandedGroups, setExpandedGroups] = useState>({}) - - // 初始化:自动展开包含当前活跃项的分组 - const initialExpandedGroups = useMemo(() => { + const autoExpanded = useMemo(() => { const result: Record = {} - MENU_ITEMS.forEach((section, index) => { - const hasActive = section.items.some(item => item.key === currentKey) - if (hasActive || index === 0) { - result[section.group] = true + + if (navGroups.length === 0) return result + + let activeGroupCode: string | null = null + navGroups.forEach((group) => { + const hasActive = group.articles.some( + article => String(article.id) === currentArticleId, + ) + if (hasActive) { + activeGroupCode = group.code } }) + + result[navGroups[0].code] = true + + if (activeGroupCode && activeGroupCode !== navGroups[0].code) { + result[activeGroupCode] = true + } + return result - }, [currentKey]) + }, [navGroups, currentArticleId]) - // 合并自动展开和用户手动切换 - const finalExpandedGroups = useMemo(() => { - return {...initialExpandedGroups, ...expandedGroups} - }, [initialExpandedGroups, expandedGroups]) + const expandedGroups = useMemo(() => { + return {...autoExpanded, ...manualExpanded} + }, [autoExpanded, manualExpanded]) - const toggleGroup = (group: string) => { - setExpandedGroups(prev => ({ + const toggleGroup = (groupCode: string) => { + setManualExpanded(prev => ({ ...prev, - [group]: !finalExpandedGroups[group], + [groupCode]: !expandedGroups[groupCode], })) } - const getItemHref = (key: string) => `/docs/${key}` + const getActiveArticleId = () => { + return currentArticleId || '' + } + + if (loading) { + return ( + + ) + } + + if (navGroups.length === 0) { + return ( + + ) + } return ( -