帮助中心数据实现动态渲染
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -35,6 +35,7 @@
|
|||||||
"lucide-react": "^0.479.0",
|
"lucide-react": "^0.479.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"photoswipe": "^5.4.4",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-day-picker": "8.10.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=="],
|
"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=="],
|
"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=="],
|
"picomatch": ["picomatch@4.0.3", "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"lucide-react": "^0.479.0",
|
"lucide-react": "^0.479.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.0.10",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"photoswipe": "^5.4.4",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.1",
|
"react": "^19.2.1",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "8.10.1",
|
||||||
|
|||||||
12
src/actions/article.ts
Normal file
12
src/actions/article.ts
Normal file
@@ -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<ArticleNavGroup[]>('/api/article/nav', params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getArticleDetail(params: {id: number}) {
|
||||||
|
return await callByDevice<ArticleDetail>('/api/article/get', params)
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="prose prose-slate max-w-none [&_img]:cursor-zoom-in"
|
||||||
|
dangerouslySetInnerHTML={{__html: content}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
60
src/app/(home)/docs/[groupCode]/[articleId]/page.tsx
Normal file
60
src/app/(home)/docs/[groupCode]/[articleId]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-6 pb-4 border-b">
|
||||||
|
<div className="h-8 bg-gray-200 rounded animate-pulse mb-4 w-3/4"/>
|
||||||
|
<div className="h-4 bg-gray-100 rounded animate-pulse w-1/3"/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[1, 2, 3, 4].map(i => (
|
||||||
|
<div key={i} className="h-4 bg-gray-100 rounded animate-pulse"/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="mb-6 pb-4 border-b">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold text-slate-900 mb-2">
|
||||||
|
{article.title}
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-slate-500">
|
||||||
|
<span>更新日期:{formatDate(article.updated_at, 'YYYY-MM-DD HH:mm:ss')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArticleViewer content={article.content || ''}/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ArticlePage(props: ArticlePageProps) {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<ArticleLoadingSkeleton/>}>
|
||||||
|
<ArticleContent params={props.params}/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ import {Children} from '@/lib/utils'
|
|||||||
import Sidebar from './sidebar'
|
import Sidebar from './sidebar'
|
||||||
import HomePage from '@/components/home/page'
|
import HomePage from '@/components/home/page'
|
||||||
import SidebarDrawer from './sidebar-drawer'
|
import SidebarDrawer from './sidebar-drawer'
|
||||||
|
import {Suspense} from 'react'
|
||||||
|
|
||||||
export default function DocsLayout(props: Children) {
|
export default function DocsLayout(props: Children) {
|
||||||
return (
|
return (
|
||||||
<HomePage path={[{label: '帮助中心', href: '/docs'}]}>
|
<HomePage path={[{label: '帮助中心', href: '/docs'}]}>
|
||||||
<Wrap className="flex gap-3 flex-col md:flex-row">
|
<Wrap className="flex gap-3 flex-col md:flex-row">
|
||||||
<SidebarDrawer/>
|
<SidebarDrawer/>
|
||||||
<Sidebar className="hidden md:block w-68"/>
|
<Suspense> <Sidebar className="hidden md:block w-68"/></Suspense>
|
||||||
<div className="flex-1 bg-white rounded-lg p-4 md:p-6 min-h-[420px]">
|
<div className="flex-1 bg-white rounded-lg p-4 md:p-6 min-h-[420px]">
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,59 +1,11 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import {useState, useMemo, useCallback} from 'react'
|
import {useState, useEffect, useMemo} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {usePathname} from 'next/navigation'
|
import {usePathname} from 'next/navigation'
|
||||||
import {ChevronRight} from 'lucide-react'
|
import {ChevronRight} from 'lucide-react'
|
||||||
import {merge} from '@/lib/utils'
|
import {merge} from '@/lib/utils'
|
||||||
|
import {getArticleNav} from '@/actions/article'
|
||||||
// 菜单配置
|
import type {ArticleNavGroup} from '@/lib/models/article'
|
||||||
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: '网站公告'},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -62,88 +14,126 @@ type Props = {
|
|||||||
|
|
||||||
export default function Sidebar({className, onClose}: Props) {
|
export default function Sidebar({className, onClose}: Props) {
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
|
const [navGroups, setNavGroups] = useState<ArticleNavGroup[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [manualExpanded, setManualExpanded] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
// 获取当前文档 key
|
useEffect(() => {
|
||||||
const getCurrentKey = useCallback(() => {
|
const loadNav = async () => {
|
||||||
const parts = pathname?.split('/') || []
|
const resp = await getArticleNav({})
|
||||||
return parts[2] || ''
|
if (resp.success) {
|
||||||
}, [pathname])
|
setNavGroups(resp.data || [])
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
loadNav()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const currentKey = getCurrentKey()
|
const parts = pathname?.split('/') || []
|
||||||
|
const currentArticleId = parts[3]
|
||||||
|
|
||||||
// 展开/收起状态
|
const autoExpanded = useMemo(() => {
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({})
|
|
||||||
|
|
||||||
// 初始化:自动展开包含当前活跃项的分组
|
|
||||||
const initialExpandedGroups = useMemo(() => {
|
|
||||||
const result: Record<string, boolean> = {}
|
const result: Record<string, boolean> = {}
|
||||||
MENU_ITEMS.forEach((section, index) => {
|
|
||||||
const hasActive = section.items.some(item => item.key === currentKey)
|
if (navGroups.length === 0) return result
|
||||||
if (hasActive || index === 0) {
|
|
||||||
result[section.group] = true
|
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
|
return result
|
||||||
}, [currentKey])
|
}, [navGroups, currentArticleId])
|
||||||
|
|
||||||
// 合并自动展开和用户手动切换
|
const expandedGroups = useMemo(() => {
|
||||||
const finalExpandedGroups = useMemo(() => {
|
return {...autoExpanded, ...manualExpanded}
|
||||||
return {...initialExpandedGroups, ...expandedGroups}
|
}, [autoExpanded, manualExpanded])
|
||||||
}, [initialExpandedGroups, expandedGroups])
|
|
||||||
|
|
||||||
const toggleGroup = (group: string) => {
|
const toggleGroup = (groupCode: string) => {
|
||||||
setExpandedGroups(prev => ({
|
setManualExpanded(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[group]: !finalExpandedGroups[group],
|
[groupCode]: !expandedGroups[groupCode],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getItemHref = (key: string) => `/docs/${key}`
|
const getActiveArticleId = () => {
|
||||||
|
return currentArticleId || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<aside className={merge('bg-white rounded-lg p-3', className)}>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[1, 2, 3].map(i => (
|
||||||
|
<div key={i} className="h-10 bg-gray-100 rounded animate-pulse"/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navGroups.length === 0) {
|
||||||
|
return (
|
||||||
|
<aside className={merge('bg-white rounded-lg p-3', className)}>
|
||||||
|
<div className="text-center text-slate-400 py-4">
|
||||||
|
暂无文档
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside className={merge('bg-white rounded-lg p-3 transition-all duration-200 shrink-0', className)}>
|
||||||
className={merge(`bg-white rounded-lg p-3 transition-all duration-200 shrink-0`, className)}
|
|
||||||
>
|
|
||||||
<nav className="space-y-2">
|
<nav className="space-y-2">
|
||||||
{MENU_ITEMS.map(section => (
|
{navGroups.map(group => (
|
||||||
<div key={section.group}>
|
<div key={group.code}>
|
||||||
<div
|
<div
|
||||||
onClick={() => toggleGroup(section.group)}
|
onClick={() => toggleGroup(group.code)}
|
||||||
className={`flex items-center gap-2 cursor-pointer px-3 py-2 rounded-sm transition-colors ${
|
className={`flex items-center gap-2 cursor-pointer px-3 py-2 rounded-sm transition-colors ${
|
||||||
finalExpandedGroups[section.group] && 'bg-blue-50'
|
expandedGroups[group.code] && 'bg-blue-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-4 flex items-center justify-center text-sm text-slate-400 transform transition-transform ${
|
className={`w-4 flex items-center justify-center text-sm text-slate-400 transform transition-transform ${
|
||||||
finalExpandedGroups[section.group] ? 'rotate-90' : ''
|
expandedGroups[group.code] ? 'rotate-90' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ChevronRight size={16}/>
|
<ChevronRight size={16}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-lg font-semibold text-slate-900">
|
<div className="text-lg font-semibold text-slate-900">
|
||||||
{section.group}
|
{group.name}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{finalExpandedGroups[section.group] && (
|
{expandedGroups[group.code] && (
|
||||||
<ul className="mt-1 text-base">
|
<ul className="mt-1 text-base">
|
||||||
{section.items.map((item) => {
|
{group.articles.map((article) => {
|
||||||
const isActive = currentKey === item.key
|
const isActive = getActiveArticleId() === String(article.id)
|
||||||
const href = getItemHref(item.key)
|
const href = `/docs/${group.code}/${article.id}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={item.key}>
|
<li key={article.id}>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
onClick={() => onClose?.()}
|
onClick={() => onClose?.()}
|
||||||
className={`block pl-8 py-2 text-base cursor-pointer transition-colors ${
|
className={`block pl-8 py-2 text-base cursor-pointer transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-blue-50 font-semibold'
|
? 'bg-blue-50 font-semibold text-blue-600'
|
||||||
: 'text-slate-700 hover:text-slate-900 hover:bg-slate-50'
|
: 'text-slate-700 hover:text-slate-900 hover:bg-slate-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.label}
|
{article.title}
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -156,8 +156,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* highlight.js 样式覆盖 */
|
/* highlight.js 样式覆盖 - 确保代码块高亮在前台正常显示 */
|
||||||
pre code.hljs {
|
.prose pre {
|
||||||
background: inherit;
|
background: #2b2b2b;
|
||||||
padding: 0;
|
color: #abb2bf;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose pre code.hljs {
|
||||||
|
background: inherit;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/lib/models/article.ts
Normal file
26
src/lib/models/article.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type ArticleNavGroup = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
articles: ArticleNavItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleNavItem = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleDetail = {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
updated_at: string
|
||||||
|
group: ArticleGroupInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ArticleGroupInfo = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
code: string
|
||||||
|
}
|
||||||
21
src/lib/utils/date.ts
Normal file
21
src/lib/utils/date.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export function formatDate(
|
||||||
|
dateStr?: string | null,
|
||||||
|
format: string = 'YYYY-MM-DD',
|
||||||
|
fallback: string = '-',
|
||||||
|
): string {
|
||||||
|
if (!dateStr) return fallback
|
||||||
|
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
if (isNaN(date.getTime())) return fallback
|
||||||
|
|
||||||
|
const map: Record<string, string | number> = {
|
||||||
|
YYYY: date.getFullYear(),
|
||||||
|
MM: String(date.getMonth() + 1).padStart(2, '0'),
|
||||||
|
DD: String(date.getDate()).padStart(2, '0'),
|
||||||
|
HH: String(date.getHours()).padStart(2, '0'),
|
||||||
|
mm: String(date.getMinutes()).padStart(2, '0'),
|
||||||
|
ss: String(date.getSeconds()).padStart(2, '0'),
|
||||||
|
}
|
||||||
|
|
||||||
|
return format.replace(/YYYY|MM|DD|HH|mm|ss/g, matched => String(map[matched]))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user