feat(homepage): align product and contact layout (#530)
This commit is contained in:
parent
afc11692e1
commit
822a45016c
@ -1,26 +1,35 @@
|
||||
export const dynamic = 'error'
|
||||
|
||||
import Hero from '@components/Hero'
|
||||
import Features from '@components/Features'
|
||||
import OpenSource from '@components/OpenSource'
|
||||
import DownloadSection from '@components/DownloadSection'
|
||||
import Terms from '@components/Terms'
|
||||
import Contact from '@components/Contact'
|
||||
import Footer from '@components/Footer'
|
||||
import Navbar from '@components/Navbar'
|
||||
import { AskAIButton } from '@components/AskAIButton'
|
||||
|
||||
import ArticleFeed from '@components/home/ArticleFeed'
|
||||
import ContactPanel from '@components/home/ContactPanel'
|
||||
import HeroBanner from '@components/home/HeroBanner'
|
||||
import ProductMatrix from '@components/home/ProductMatrix'
|
||||
import Sidebar from '@components/home/Sidebar'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="pt-24">
|
||||
<Hero />
|
||||
<Features />
|
||||
<OpenSource />
|
||||
<DownloadSection />
|
||||
<Terms />
|
||||
<Contact />
|
||||
<Navbar />
|
||||
<main className="bg-slate-50 pb-16 pt-24">
|
||||
<HeroBanner />
|
||||
<section className="relative z-10 -mt-12 px-4 sm:-mt-20">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="grid gap-8 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||
<div className="space-y-8">
|
||||
<ProductMatrix />
|
||||
<ArticleFeed />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<ContactPanel />
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
<AskAIButton />
|
||||
|
||||
82
ui/homepage/components/home/ArticleFeed.tsx
Normal file
82
ui/homepage/components/home/ArticleFeed.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { getHomepagePosts } from '@lib/homepageContent'
|
||||
|
||||
function formatDate(value?: string) {
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export default async function ArticleFeed() {
|
||||
const posts = await getHomepagePosts()
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-600">最新动态</p>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">产品与社区快讯</h2>
|
||||
</div>
|
||||
<Link href="/docs" className="text-sm font-medium text-sky-600 hover:text-sky-700">
|
||||
浏览全部更新 →
|
||||
</Link>
|
||||
</header>
|
||||
<div className="space-y-6">
|
||||
{!posts.length ? (
|
||||
<p className="rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center text-sm text-slate-500">
|
||||
暂无内容,敬请期待更多来自产品与社区的最新动态。
|
||||
</p>
|
||||
) : null}
|
||||
{posts.map((post) => {
|
||||
const formattedDate = formatDate(post.date)
|
||||
return (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="group rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg sm:p-8"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-500 sm:text-sm">
|
||||
{formattedDate ? <span>{formattedDate}</span> : null}
|
||||
{post.author ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="hidden h-1 w-1 rounded-full bg-slate-400 sm:inline" aria-hidden />
|
||||
<span>{post.author}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{post.readingTime ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="hidden h-1 w-1 rounded-full bg-slate-400 sm:inline" aria-hidden />
|
||||
<span>{post.readingTime}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold text-slate-900 transition group-hover:text-sky-600 sm:text-2xl">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.excerpt ? <p className="mt-3 text-sm text-slate-600 sm:text-base">{post.excerpt}</p> : null}
|
||||
{post.tags.length ? (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{post.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
17
ui/homepage/components/home/ContactPanel.tsx
Normal file
17
ui/homepage/components/home/ContactPanel.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import ContactPanelClient from './ContactPanelClient'
|
||||
|
||||
import { getContactPanelContent } from '@lib/homepageContent'
|
||||
|
||||
type ContactPanelProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default async function ContactPanel({ className }: ContactPanelProps = {}) {
|
||||
const panel = await getContactPanelContent()
|
||||
|
||||
if (!panel || !panel.items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <ContactPanelClient panel={panel} className={className} />
|
||||
}
|
||||
232
ui/homepage/components/home/ContactPanelClient.tsx
Normal file
232
ui/homepage/components/home/ContactPanelClient.tsx
Normal file
@ -0,0 +1,232 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LifeBuoy,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { ContactPanelContent, ContactItemContent } from '@lib/homepageContent'
|
||||
|
||||
const STORAGE_KEY = 'xcontrol-homepage-contact-collapsed'
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
mail: Mail,
|
||||
'life-buoy': LifeBuoy,
|
||||
}
|
||||
|
||||
function getIcon(name?: string): LucideIcon {
|
||||
if (!name) {
|
||||
return MessageCircle
|
||||
}
|
||||
const normalized = name.toLowerCase()
|
||||
return iconMap[normalized] ?? MessageCircle
|
||||
}
|
||||
|
||||
type ContactPanelClientProps = {
|
||||
panel: ContactPanelContent
|
||||
className?: string
|
||||
}
|
||||
|
||||
const QR_GRID_SIZE = 21
|
||||
const FINDER_SIZE = 7
|
||||
|
||||
function createPseudoQrPattern(value: string): boolean[][] {
|
||||
const size = QR_GRID_SIZE
|
||||
const pattern: boolean[][] = Array.from({ length: size }, () => Array<boolean>(size).fill(false))
|
||||
|
||||
let seed = 0
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
seed = (seed * 31 + value.charCodeAt(index)) >>> 0
|
||||
}
|
||||
seed ^= value.length << 7
|
||||
|
||||
const finderAnchors: Array<[number, number]> = [
|
||||
[0, 0],
|
||||
[size - FINDER_SIZE, 0],
|
||||
[0, size - FINDER_SIZE],
|
||||
]
|
||||
|
||||
const isFinder = (x: number, y: number) => {
|
||||
for (const [px, py] of finderAnchors) {
|
||||
if (x >= px && x < px + FINDER_SIZE && y >= py && y < py + FINDER_SIZE) {
|
||||
const innerX = x - px
|
||||
const innerY = y - py
|
||||
if (innerX === 0 || innerX === FINDER_SIZE - 1 || innerY === 0 || innerY === FINDER_SIZE - 1) {
|
||||
return true
|
||||
}
|
||||
if (innerX >= 2 && innerX <= FINDER_SIZE - 3 && innerY >= 2 && innerY <= FINDER_SIZE - 3) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (let y = 0; y < size; y += 1) {
|
||||
for (let x = 0; x < size; x += 1) {
|
||||
if (isFinder(x, y)) {
|
||||
pattern[y][x] = true
|
||||
continue
|
||||
}
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0
|
||||
const threshold = seed & 0xff
|
||||
pattern[y][x] = threshold % 3 !== 0
|
||||
}
|
||||
}
|
||||
|
||||
return pattern
|
||||
}
|
||||
|
||||
type QrPreviewProps = {
|
||||
item: ContactItemContent
|
||||
}
|
||||
|
||||
function QrPreview({ item }: QrPreviewProps) {
|
||||
const pattern = useMemo(() => createPseudoQrPattern(item.qrValue ?? item.slug), [item.qrValue, item.slug])
|
||||
const size = pattern.length
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl border border-slate-200 bg-white/90 p-3 shadow-inner"
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className="grid aspect-square w-full"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${size}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${size}, minmax(0, 1fr))`,
|
||||
gap: '1px',
|
||||
}}
|
||||
>
|
||||
{pattern.flat().map((isFilled, index) => (
|
||||
<span
|
||||
key={`${item.slug}-${index}`}
|
||||
className={`block ${isFilled ? 'bg-slate-900' : 'bg-slate-50'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
|
||||
{item.description ? <p className="text-xs text-slate-500">{item.description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InfoCard({ item }: { item: ContactItemContent }) {
|
||||
const Icon = getIcon(item.icon)
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-2xl border border-slate-200/80 bg-slate-50/80 p-4">
|
||||
<div className="mt-1 rounded-full bg-sky-500/10 p-2 text-sky-600">
|
||||
<Icon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-slate-900">{item.title}</p>
|
||||
{item.description ? <p className="mt-1 text-xs text-slate-500">{item.description}</p> : null}
|
||||
{item.bodyHtml ? (
|
||||
<div
|
||||
className="prose prose-sm mt-2 max-w-none text-slate-600"
|
||||
dangerouslySetInnerHTML={{ __html: item.bodyHtml }}
|
||||
/>
|
||||
) : null}
|
||||
{item.ctaLabel && item.ctaHref ? (
|
||||
<Link
|
||||
href={item.ctaHref}
|
||||
className="mt-3 inline-flex items-center gap-1 text-sm font-semibold text-sky-600 transition hover:text-sky-700"
|
||||
>
|
||||
{item.ctaLabel}
|
||||
<ChevronRight className="h-4 w-4" aria-hidden />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContactPanelClient({ panel, className }: ContactPanelClientProps) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored === 'true') {
|
||||
setCollapsed(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(STORAGE_KEY, collapsed ? 'true' : 'false')
|
||||
}, [collapsed])
|
||||
|
||||
if (!panel.items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('w-full', className)}>
|
||||
{collapsed ? (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
className="group inline-flex items-center gap-2 rounded-full border border-sky-400/60 bg-white px-4 py-2 text-sm font-semibold text-sky-700 shadow-sm shadow-sky-200/60"
|
||||
aria-label="展开保持联系面板"
|
||||
>
|
||||
<span>保持联系</span>
|
||||
<ChevronLeft className="h-4 w-4 transition group-hover:translate-x-0.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<section className="relative overflow-hidden rounded-3xl border border-slate-200/80 bg-white shadow-lg shadow-slate-200/40">
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-sky-400 via-cyan-400 to-indigo-400" aria-hidden />
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.3em] text-sky-500">{panel.title}</p>
|
||||
{panel.subtitle ? <p className="mt-1 text-xs text-slate-500">{panel.subtitle}</p> : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
className="rounded-full border border-slate-200 bg-white/80 p-1 text-slate-400 transition hover:text-slate-600"
|
||||
aria-label="折叠保持联系面板"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
{panel.bodyHtml ? (
|
||||
<div
|
||||
className="prose prose-sm px-5 pt-3 text-slate-600"
|
||||
dangerouslySetInnerHTML={{ __html: panel.bodyHtml }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="grid gap-4 px-5 pb-5 pt-4 sm:grid-cols-2">
|
||||
{panel.items.map((item) => {
|
||||
if (item.type === 'qr') {
|
||||
return (
|
||||
<div key={item.slug} className="sm:flex sm:flex-col">
|
||||
<QrPreview item={item} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={item.slug} className="sm:col-span-2">
|
||||
<InfoCard item={item} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
ui/homepage/components/home/HeroBanner.tsx
Normal file
92
ui/homepage/components/home/HeroBanner.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { getHomepageHero, getHeroSolutions } from '@lib/homepageContent'
|
||||
import HeroProductTabs from './HeroProductTabs'
|
||||
|
||||
const gradientOverlay =
|
||||
'absolute inset-0 bg-gradient-to-br from-slate-900/90 via-slate-900/80 to-sky-900/70'
|
||||
|
||||
export default async function HeroBanner() {
|
||||
const [hero, solutions] = await Promise.all([getHomepageHero(), getHeroSolutions()])
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-slate-950">
|
||||
<div className="absolute inset-0">
|
||||
<div className="h-full w-full bg-[radial-gradient(circle_at_top,_rgba(59,130,246,0.25),_transparent_55%)]" />
|
||||
</div>
|
||||
<div className={gradientOverlay} aria-hidden="true" />
|
||||
<div className="relative mx-auto flex max-w-6xl flex-col gap-10 px-4 py-16 text-white sm:py-20 lg:flex-row lg:items-center lg:py-24">
|
||||
<div className="flex-1 space-y-6">
|
||||
{hero.eyebrow ? (
|
||||
<span className="inline-flex items-center rounded-full bg-white/10 px-4 py-1 text-sm font-medium tracking-wide">
|
||||
{hero.eyebrow}
|
||||
</span>
|
||||
) : null}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl lg:text-5xl">
|
||||
{hero.title}
|
||||
</h1>
|
||||
{hero.subtitle ? (
|
||||
<p className="text-base text-slate-200 sm:text-lg lg:text-xl">{hero.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{hero.highlights.length ? (
|
||||
<ul className="grid gap-3 text-sm text-slate-200 sm:grid-cols-2 sm:text-base">
|
||||
{hero.highlights.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<span className="mt-1 inline-block h-2 w-2 flex-shrink-0 rounded-full bg-sky-400" aria-hidden />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{hero.bodyHtml ? (
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-sm text-slate-200 sm:text-base"
|
||||
dangerouslySetInnerHTML={{ __html: hero.bodyHtml }}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||
{hero.primaryCtaLabel && hero.primaryCtaHref ? (
|
||||
<Link
|
||||
href={hero.primaryCtaHref}
|
||||
className="inline-flex items-center justify-center rounded-full bg-sky-400 px-5 py-2 text-sm font-semibold text-slate-950 shadow-lg transition hover:bg-sky-300"
|
||||
>
|
||||
{hero.primaryCtaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
{hero.secondaryCtaLabel && hero.secondaryCtaHref ? (
|
||||
<Link
|
||||
href={hero.secondaryCtaHref}
|
||||
className="inline-flex items-center justify-center rounded-full border border-white/40 px-5 py-2 text-sm font-semibold text-white transition hover:border-white"
|
||||
>
|
||||
{hero.secondaryCtaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{solutions.length ? (
|
||||
<HeroProductTabs items={solutions} />
|
||||
) : (
|
||||
<div className="rounded-3xl border border-white/10 bg-white/5 p-6 backdrop-blur-lg sm:p-8 lg:p-10">
|
||||
<h2 className="text-lg font-semibold text-white sm:text-xl">平台概览</h2>
|
||||
<p className="mt-3 text-sm text-slate-200 sm:text-base">
|
||||
通过统一的控制平面与开放接口,XControl 将治理、观测、安全与工作流整合为一体,让团队可以自信地扩展云原生业务。
|
||||
</p>
|
||||
{hero.highlights.length ? (
|
||||
<dl className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
{hero.highlights.slice(0, 4).map((item) => (
|
||||
<div key={item} className="rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-slate-100">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
171
ui/homepage/components/home/HeroProductTabs.tsx
Normal file
171
ui/homepage/components/home/HeroProductTabs.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useId, useMemo, useState, type KeyboardEvent } from 'react'
|
||||
|
||||
import type { HeroSolution } from '@lib/homepageContent'
|
||||
|
||||
const ROTATION_INTERVAL_MS = 8000
|
||||
|
||||
type HeroProductTabsProps = {
|
||||
items: HeroSolution[]
|
||||
}
|
||||
|
||||
export default function HeroProductTabs({ items }: HeroProductTabsProps) {
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const tablistId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
if (items.length <= 1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
setActiveIndex((current) => {
|
||||
const nextIndex = current + 1
|
||||
return nextIndex >= items.length ? 0 : nextIndex
|
||||
})
|
||||
}, ROTATION_INTERVAL_MS)
|
||||
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeIndex, items.length])
|
||||
|
||||
const activeItem = items[activeIndex] ?? items[0]
|
||||
|
||||
const panelId = useMemo(() => {
|
||||
if (!activeItem) {
|
||||
return undefined
|
||||
}
|
||||
return `${tablistId}-panel-${activeItem.slug}`
|
||||
}, [activeItem, tablistId])
|
||||
|
||||
if (!items.length || !activeItem) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
if (items.length <= 1) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setActiveIndex(index === items.length - 1 ? 0 : index + 1)
|
||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setActiveIndex(index === 0 ? items.length - 1 : index - 1)
|
||||
} else if (event.key === 'Home') {
|
||||
event.preventDefault()
|
||||
setActiveIndex(0)
|
||||
} else if (event.key === 'End') {
|
||||
event.preventDefault()
|
||||
setActiveIndex(items.length - 1)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full flex-col overflow-hidden rounded-3xl border border-white/10 bg-white/5 p-6 text-white shadow-xl backdrop-blur-lg sm:p-8">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.35em] text-sky-300/90">产品矩阵</span>
|
||||
<div
|
||||
id={tablistId}
|
||||
role="tablist"
|
||||
aria-label="XControl 产品套件"
|
||||
className="mt-4 flex flex-wrap gap-2"
|
||||
>
|
||||
{items.map((item, index) => {
|
||||
const isActive = index === activeIndex
|
||||
const tabId = `${tablistId}-tab-${item.slug}`
|
||||
const targetPanelId = `${tablistId}-panel-${item.slug}`
|
||||
return (
|
||||
<button
|
||||
key={item.slug}
|
||||
id={tabId}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={targetPanelId}
|
||||
className={`group flex min-w-[9rem] flex-col rounded-2xl border px-4 py-3 text-left text-sm font-semibold transition focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-200/80 ${
|
||||
isActive
|
||||
? 'border-sky-300/80 bg-sky-300/90 text-slate-900 shadow-lg shadow-sky-500/30'
|
||||
: 'border-white/10 bg-white/5 text-slate-100 hover:border-white/30 hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => setActiveIndex(index)}
|
||||
onKeyDown={(event) => handleKeyDown(event, index)}
|
||||
>
|
||||
<span className="text-base font-semibold leading-tight">{item.title}</span>
|
||||
{item.tagline ? (
|
||||
<span
|
||||
className={`mt-1 text-xs font-medium transition ${
|
||||
isActive ? 'text-slate-800/80' : 'text-slate-200/70 group-hover:text-slate-100'
|
||||
}`}
|
||||
>
|
||||
{item.tagline}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={panelId}
|
||||
aria-labelledby={`${tablistId}-tab-${activeItem.slug}`}
|
||||
className="mt-6 flex flex-1 flex-col rounded-2xl border border-white/10 bg-slate-950/30 p-6 shadow-inner shadow-slate-950/40 sm:p-7"
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
{activeItem.tagline ? (
|
||||
<p className="text-sm font-medium uppercase tracking-[0.3em] text-sky-200/80">
|
||||
{activeItem.tagline}
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="text-2xl font-semibold sm:text-3xl">{activeItem.title}</h2>
|
||||
{activeItem.description ? (
|
||||
<p className="text-sm text-slate-200/90 sm:text-base">{activeItem.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{activeItem.features.length ? (
|
||||
<ul className="mt-5 grid gap-3 text-sm sm:grid-cols-2 sm:text-base">
|
||||
{activeItem.features.map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-start gap-3 rounded-xl border border-white/10 bg-white/5 p-3 text-slate-100"
|
||||
>
|
||||
<span className="mt-1 inline-block h-2 w-2 flex-shrink-0 rounded-full bg-sky-400" aria-hidden />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{activeItem.bodyHtml ? (
|
||||
<div
|
||||
className="prose prose-invert mt-5 max-w-none text-sm text-slate-200/90 [&_strong]:text-white"
|
||||
dangerouslySetInnerHTML={{ __html: activeItem.bodyHtml }}
|
||||
/>
|
||||
) : null}
|
||||
{(activeItem.primaryCtaLabel && activeItem.primaryCtaHref) ||
|
||||
(activeItem.secondaryCtaLabel && activeItem.secondaryCtaHref) ? (
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
{activeItem.primaryCtaLabel && activeItem.primaryCtaHref ? (
|
||||
<Link
|
||||
href={activeItem.primaryCtaHref}
|
||||
className="inline-flex items-center justify-center rounded-full bg-sky-400 px-5 py-2 text-sm font-semibold text-slate-950 shadow-lg transition hover:bg-sky-300"
|
||||
>
|
||||
{activeItem.primaryCtaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
{activeItem.secondaryCtaLabel && activeItem.secondaryCtaHref ? (
|
||||
<Link
|
||||
href={activeItem.secondaryCtaHref}
|
||||
className="inline-flex items-center justify-center rounded-full border border-white/40 px-5 py-2 text-sm font-semibold text-white transition hover:border-white"
|
||||
>
|
||||
{activeItem.secondaryCtaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-slate-950/60 via-slate-950/0" aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
ui/homepage/components/home/ProductMatrix.tsx
Normal file
89
ui/homepage/components/home/ProductMatrix.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import { getHeroSolutions } from '@lib/homepageContent'
|
||||
|
||||
function truncate(text: string, maxLength: number) {
|
||||
if (text.length <= maxLength) {
|
||||
return text
|
||||
}
|
||||
return `${text.slice(0, maxLength - 1)}…`
|
||||
}
|
||||
|
||||
export default async function ProductMatrix() {
|
||||
const solutions = await getHeroSolutions()
|
||||
|
||||
if (!solutions.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<header className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-sky-600">产品矩阵</p>
|
||||
<h2 className="text-2xl font-semibold text-slate-900 sm:text-3xl">旗舰能力一览</h2>
|
||||
</div>
|
||||
<Link href="/docs" className="text-sm font-medium text-sky-600 hover:text-sky-700">
|
||||
查看全部方案 →
|
||||
</Link>
|
||||
</header>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{solutions.map((solution) => (
|
||||
<article
|
||||
key={solution.slug}
|
||||
className="group relative overflow-hidden rounded-3xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg sm:p-7"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-1 bg-gradient-to-r from-sky-400 via-cyan-400 to-indigo-400 opacity-0 transition group-hover:opacity-100" aria-hidden />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 transition group-hover:text-sky-600">
|
||||
{solution.title}
|
||||
</h3>
|
||||
{solution.tagline ? (
|
||||
<p className="mt-1 text-sm text-slate-500">{truncate(solution.tagline, 60)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{solution.features.length ? (
|
||||
<ul className="space-y-2 text-sm text-slate-600">
|
||||
{solution.features.slice(0, 3).map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2">
|
||||
<span className="mt-1 inline-block h-1.5 w-1.5 flex-shrink-0 rounded-full bg-sky-400" aria-hidden />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{solution.bodyHtml ? (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-slate-600"
|
||||
dangerouslySetInnerHTML={{ __html: solution.bodyHtml }}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{(solution.primaryCtaLabel && solution.primaryCtaHref) ||
|
||||
(solution.secondaryCtaLabel && solution.secondaryCtaHref) ? (
|
||||
<div className="mt-5 flex flex-wrap gap-3">
|
||||
{solution.primaryCtaLabel && solution.primaryCtaHref ? (
|
||||
<Link
|
||||
href={solution.primaryCtaHref}
|
||||
className="inline-flex items-center justify-center rounded-full bg-sky-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-400"
|
||||
>
|
||||
{solution.primaryCtaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
{solution.secondaryCtaLabel && solution.secondaryCtaHref ? (
|
||||
<Link
|
||||
href={solution.secondaryCtaHref}
|
||||
className="inline-flex items-center justify-center rounded-full border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-700 transition hover:border-slate-300"
|
||||
>
|
||||
{solution.secondaryCtaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
15
ui/homepage/components/home/Sidebar.tsx
Normal file
15
ui/homepage/components/home/Sidebar.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { getSidebarSections } from '@lib/homepageContent'
|
||||
|
||||
import SidebarCard from './SidebarCard'
|
||||
|
||||
export default async function Sidebar() {
|
||||
const sections = await getSidebarSections()
|
||||
|
||||
return (
|
||||
<aside className="space-y-6">
|
||||
{sections.map((section) => (
|
||||
<SidebarCard key={section.slug} section={section} />
|
||||
))}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
51
ui/homepage/components/home/SidebarCard.tsx
Normal file
51
ui/homepage/components/home/SidebarCard.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { SidebarSection } from '@lib/homepageContent'
|
||||
|
||||
interface SidebarCardProps {
|
||||
section: SidebarSection
|
||||
}
|
||||
|
||||
function isValidCta(section: SidebarSection): section is SidebarSection & {
|
||||
ctaLabel: string
|
||||
ctaHref: string
|
||||
} {
|
||||
return Boolean(section.ctaLabel && section.ctaHref)
|
||||
}
|
||||
|
||||
export default function SidebarCard({ section }: SidebarCardProps) {
|
||||
const hasTagsLayout = section.layout === 'tags' && section.tags.length > 0
|
||||
|
||||
return (
|
||||
<section className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<header className="mb-4 flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{section.title}</h3>
|
||||
{isValidCta(section) ? (
|
||||
<Link
|
||||
href={section.ctaHref}
|
||||
className="text-sm font-medium text-sky-600 transition hover:text-sky-700"
|
||||
>
|
||||
{section.ctaLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</header>
|
||||
{hasTagsLayout ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{section.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-700"
|
||||
>
|
||||
#{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-slate-600 [&_a]:text-sky-600 [&_a]:no-underline hover:[&_a]:underline"
|
||||
dangerouslySetInnerHTML={{ __html: section.bodyHtml }}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
10
ui/homepage/content/homepage/contact/items/newsletter.md
Normal file
10
ui/homepage/content/homepage/contact/items/newsletter.md
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
title: 加入邮件列表
|
||||
type: info
|
||||
order: 4
|
||||
icon: mail
|
||||
description: 向开发组提交交付码或反馈意见
|
||||
ctaLabel: 立即订阅
|
||||
ctaHref: /newsletter
|
||||
---
|
||||
订阅每月通讯,获取路线图更新与最佳实践文章。
|
||||
10
ui/homepage/content/homepage/contact/items/support.md
Normal file
10
ui/homepage/content/homepage/contact/items/support.md
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
title: 获取商业支持
|
||||
type: info
|
||||
order: 3
|
||||
icon: life-buoy
|
||||
description: 了解商业产品和专业支持服务
|
||||
ctaLabel: 联系我们
|
||||
ctaHref: /support/contact
|
||||
---
|
||||
提交企业需求,专家团队将在一个工作日内回复。
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: 加入微信群
|
||||
type: qr
|
||||
order: 2
|
||||
qrValue: https://xcontrol.cloud/contact/wechat-community
|
||||
description: 与产品团队和同行实时交流
|
||||
---
|
||||
添加 XControl 社区小助手,获取最新活动信息并加入兴趣小组。
|
||||
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: 微信公众号
|
||||
type: qr
|
||||
order: 1
|
||||
qrValue: https://xcontrol.cloud/contact/wechat-official
|
||||
description: 了解商业产品和专业支持服务
|
||||
---
|
||||
关注 XControl 官方公众号,解锁上云实践案例与专家分享。
|
||||
4
ui/homepage/content/homepage/contact/panel.md
Normal file
4
ui/homepage/content/homepage/contact/panel.md
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
title: 保持联系
|
||||
subtitle: 扫码关注或加入社区,获取最新产品动态与支持。
|
||||
---
|
||||
16
ui/homepage/content/homepage/hero.md
Normal file
16
ui/homepage/content/homepage/hero.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
eyebrow: 云原生运营中心
|
||||
title: 打造一体化的 XControl 控制平面
|
||||
subtitle: 将资产管理、访问控制、可观测与自动化工作流整合到一个响应迅速的体验里,帮助团队高效落地治理策略。
|
||||
primaryCtaLabel: 立即体验
|
||||
primaryCtaHref: /register
|
||||
secondaryCtaLabel: 产品文档
|
||||
secondaryCtaHref: /docs
|
||||
highlights:
|
||||
- 跨集群纳管与多云环境统一治理
|
||||
- 以策略为核心的安全与合规编排
|
||||
- 数据驱动的可观测与成本分析
|
||||
- 场景化模板快速对接业务流程
|
||||
---
|
||||
|
||||
XControl 采用模块化架构设计,可在保持核心稳定的前提下按需引入观测、身份、编排等能力包。通过开放 API 与事件流,您可以轻松连接现有的 DevOps 工具链,让业务交付与平台治理协同运转。
|
||||
@ -0,0 +1,11 @@
|
||||
---
|
||||
title: 社区巡回沙龙启动:实践分享与产品路线解读
|
||||
author: 社区团队
|
||||
date: 2024-07-12
|
||||
readingTime: 3 分钟
|
||||
tags:
|
||||
- 社区活动
|
||||
- 生态
|
||||
---
|
||||
|
||||
今年的巡回沙龙将覆盖八个城市,围绕平台工程、自动化安全与可观测进行实战分享。我们还准备了产品路线解读与开放问答,欢迎报名参与,与来自不同行业的伙伴一起交流经验。
|
||||
@ -0,0 +1,12 @@
|
||||
---
|
||||
title: 观测即服务:从日志到业务指标的一体化洞察
|
||||
author: 平台可观测团队
|
||||
date: 2024-07-30
|
||||
readingTime: 5 分钟
|
||||
tags:
|
||||
- 最佳实践
|
||||
- 观测
|
||||
- 数据分析
|
||||
---
|
||||
|
||||
针对多租户环境,我们重构了指标与日志的归档体系,实现了秒级可视化。通过业务域建模,团队可将技术指标与业务目标关联,快速定位影响用户体验的关键链路。
|
||||
@ -0,0 +1,13 @@
|
||||
---
|
||||
title: 版本 1.8 正式发布:策略联动与可观测双升级
|
||||
author: XControl 产品团队
|
||||
date: 2024-08-15
|
||||
readingTime: 8 分钟
|
||||
tags:
|
||||
- 发布公告
|
||||
- 策略中心
|
||||
- 可观测
|
||||
excerpt: 全新策略联动引擎与跨集群指标联邦能力上线,为大规模云原生团队带来统一的治理体验。
|
||||
---
|
||||
|
||||
我们针对复杂多集群环境推出了「策略联动」功能,可将身份、资源与安全策略编织成全局一致的执行链路。与此同时,指标联邦能力支持将多源观测数据按团队、业务或环境维度聚合分析,让治理效果实时可视化。
|
||||
10
ui/homepage/content/homepage/sidebar/community.md
Normal file
10
ui/homepage/content/homepage/sidebar/community.md
Normal file
@ -0,0 +1,10 @@
|
||||
---
|
||||
title: 社区热议
|
||||
ctaLabel: 加入社区
|
||||
ctaHref: https://example.com/community
|
||||
order: 1
|
||||
---
|
||||
|
||||
- [平台工程读书会](#) —— 每周聚焦云原生治理案例
|
||||
- [Slack 交流群](#) —— 与 2000+ 从业者实时交流
|
||||
- [GitHub Issues](#) —— 提交需求与反馈问题
|
||||
8
ui/homepage/content/homepage/sidebar/newsletter.md
Normal file
8
ui/homepage/content/homepage/sidebar/newsletter.md
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: 订阅周报
|
||||
ctaLabel: 立即订阅
|
||||
ctaHref: https://example.com/newsletter
|
||||
order: 2
|
||||
---
|
||||
|
||||
获取平台路线更新、最佳实践文章与线下活动邀请,每月一次发送至您的邮箱。
|
||||
8
ui/homepage/content/homepage/sidebar/resources.md
Normal file
8
ui/homepage/content/homepage/sidebar/resources.md
Normal file
@ -0,0 +1,8 @@
|
||||
---
|
||||
title: 推荐资源
|
||||
order: 3
|
||||
---
|
||||
|
||||
1. [平台上线指南](#) — 分阶段部署 XControl 的最佳实践
|
||||
2. [安全策略手册](#) — 常见合规基线的策略模板
|
||||
3. [观测数据白皮书](#) — 如何构建统一的指标与日志视图
|
||||
12
ui/homepage/content/homepage/sidebar/tags.md
Normal file
12
ui/homepage/content/homepage/sidebar/tags.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
title: 热门标签
|
||||
layout: tags
|
||||
tags:
|
||||
- 策略驱动
|
||||
- 云原生安全
|
||||
- 平台工程
|
||||
- 观测洞察
|
||||
- 成本治理
|
||||
- 最佳实践
|
||||
order: 4
|
||||
---
|
||||
15
ui/homepage/content/homepage/solutions/xcloudflow.md
Normal file
15
ui/homepage/content/homepage/solutions/xcloudflow.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: XCloudFlow
|
||||
tagline: 多云 IaC
|
||||
order: 1
|
||||
description: 通过声明式模型统一编排多云基础设施,自动化落地资源策略与合规标准。
|
||||
primaryCtaLabel: 了解 XCloudFlow
|
||||
primaryCtaHref: /products/xcloudflow
|
||||
secondaryCtaLabel: 产品文档
|
||||
secondaryCtaHref: /docs/xcloudflow
|
||||
features:
|
||||
- 跨云资源蓝图与参数化交付
|
||||
- GitOps 工作流驱动基础设施变更
|
||||
- 内置审批、审计保障合规
|
||||
---
|
||||
XCloudFlow 将 Terraform、Pulumi 等主流 IaC 模型统一到一个工作台,为多云环境提供自助式交付与集中治理。
|
||||
15
ui/homepage/content/homepage/solutions/xcontrol.md
Normal file
15
ui/homepage/content/homepage/solutions/xcontrol.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: XControl 平台
|
||||
tagline: 云原生治理中枢
|
||||
order: 3
|
||||
description: 为多团队提供统一的权限、策略与工作流编排,让交付与治理协同无缝衔接。
|
||||
primaryCtaLabel: 申请试用
|
||||
primaryCtaHref: /trial
|
||||
secondaryCtaLabel: 查看能力矩阵
|
||||
secondaryCtaHref: /products/xcontrol#capabilities
|
||||
features:
|
||||
- 一站式权限与合规策略中心
|
||||
- 工作流自动化驱动跨团队协作
|
||||
- 可扩展插件架构连接现有系统
|
||||
---
|
||||
XControl 以策略即代码为核心,为云原生基础设施提供可观测、可治理、可审计的统一控制平面。
|
||||
15
ui/homepage/content/homepage/solutions/xscopehub.md
Normal file
15
ui/homepage/content/homepage/solutions/xscopehub.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: XScopeHub
|
||||
tagline: AI & 可观察性
|
||||
order: 2
|
||||
description: 利用 AI 驱动的分析工作台,统一日志、指标与追踪,快速定位异常并推荐修复路径。
|
||||
primaryCtaLabel: 探索 XScopeHub
|
||||
primaryCtaHref: /products/xscopehub
|
||||
secondaryCtaLabel: 体验 Demo
|
||||
secondaryCtaHref: /demo/xscopehub
|
||||
features:
|
||||
- 全栈可观察性数据联邦检索
|
||||
- 智能告警关联与根因分析
|
||||
- 预置 AI 助手生成运维建议
|
||||
---
|
||||
XScopeHub 通过语义化检索与时序分析,实现跨环境的可观察性汇聚与智能洞察。
|
||||
15
ui/homepage/content/homepage/solutions/xstream.md
Normal file
15
ui/homepage/content/homepage/solutions/xstream.md
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
title: XStream
|
||||
tagline: 网络加速器
|
||||
order: 4
|
||||
description: 按需构建全球传输网络,保障跨地域应用与数据同步的稳定低时延体验。
|
||||
primaryCtaLabel: 查看加速方案
|
||||
primaryCtaHref: /products/xstream
|
||||
secondaryCtaLabel: 下载白皮书
|
||||
secondaryCtaHref: /resources/xstream-whitepaper
|
||||
features:
|
||||
- 动态最优路径与带宽调度
|
||||
- 内置零信任安全与访问控制
|
||||
- 对接主流 CDN 与边缘节点
|
||||
---
|
||||
XStream 通过软件定义的网络加速技术,为实时互动、音视频与数据分发提供稳定的全球链路。
|
||||
314
ui/homepage/lib/homepageContent.ts
Normal file
314
ui/homepage/lib/homepageContent.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { readMarkdownDirectory, readMarkdownFile } from './markdown'
|
||||
|
||||
export interface HeroContent {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
primaryCtaLabel?: string
|
||||
primaryCtaHref?: string
|
||||
secondaryCtaLabel?: string
|
||||
secondaryCtaHref?: string
|
||||
highlights: string[]
|
||||
bodyHtml: string
|
||||
}
|
||||
|
||||
export interface HeroSolution {
|
||||
slug: string
|
||||
title: string
|
||||
tagline?: string
|
||||
description?: string
|
||||
features: string[]
|
||||
bodyHtml: string
|
||||
primaryCtaLabel?: string
|
||||
primaryCtaHref?: string
|
||||
secondaryCtaLabel?: string
|
||||
secondaryCtaHref?: string
|
||||
}
|
||||
|
||||
export interface HomepagePost {
|
||||
slug: string
|
||||
title: string
|
||||
author?: string
|
||||
date?: string
|
||||
readingTime?: string
|
||||
tags: string[]
|
||||
excerpt: string
|
||||
contentHtml: string
|
||||
}
|
||||
|
||||
export interface SidebarSection {
|
||||
slug: string
|
||||
title: string
|
||||
layout?: string
|
||||
tags: string[]
|
||||
bodyHtml: string
|
||||
ctaLabel?: string
|
||||
ctaHref?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface ContactItemContent {
|
||||
slug: string
|
||||
title: string
|
||||
type?: string
|
||||
description?: string
|
||||
bodyHtml: string
|
||||
qrValue?: string
|
||||
icon?: string
|
||||
ctaLabel?: string
|
||||
ctaHref?: string
|
||||
}
|
||||
|
||||
export interface ContactPanelContent {
|
||||
title: string
|
||||
subtitle?: string
|
||||
bodyHtml?: string
|
||||
items: ContactItemContent[]
|
||||
}
|
||||
|
||||
const HOMEPAGE_CONTENT_ROOT = path.join(process.cwd(), 'content', 'homepage')
|
||||
|
||||
function ensureString(value: unknown): string | undefined {
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : undefined
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
const stringItem = ensureString(item)
|
||||
if (stringItem) {
|
||||
return stringItem
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function ensureStringArray(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => ensureString(item))
|
||||
.filter((item): item is string => Boolean(item && item.trim()))
|
||||
}
|
||||
const single = ensureString(value)
|
||||
return single ? [single] : []
|
||||
}
|
||||
|
||||
function ensureNumber(value: unknown): number | undefined {
|
||||
const stringValue = ensureString(value)
|
||||
if (!stringValue) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = Number(stringValue)
|
||||
return Number.isFinite(parsed) ? parsed : undefined
|
||||
}
|
||||
|
||||
function extractExcerpt(markdown: string): string {
|
||||
const blocks = markdown.split(/\r?\n\s*\r?\n/)
|
||||
for (const block of blocks) {
|
||||
const trimmed = block.trim()
|
||||
if (!trimmed) continue
|
||||
const withoutFormatting = trimmed
|
||||
.replace(/^#+\s*/g, '')
|
||||
.replace(/[`*_>\[\]]/g, '')
|
||||
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
|
||||
if (withoutFormatting.trim()) {
|
||||
return withoutFormatting.trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export async function getHomepageHero(): Promise<HeroContent> {
|
||||
const hero = await readMarkdownFile('hero.md', { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
|
||||
return {
|
||||
eyebrow: ensureString(hero.metadata.eyebrow),
|
||||
title: ensureString(hero.metadata.title) ?? '欢迎来到 XControl',
|
||||
subtitle: ensureString(hero.metadata.subtitle),
|
||||
primaryCtaLabel: ensureString(hero.metadata.primaryCtaLabel),
|
||||
primaryCtaHref: ensureString(hero.metadata.primaryCtaHref),
|
||||
secondaryCtaLabel: ensureString(hero.metadata.secondaryCtaLabel),
|
||||
secondaryCtaHref: ensureString(hero.metadata.secondaryCtaHref),
|
||||
highlights: ensureStringArray(hero.metadata.highlights),
|
||||
bodyHtml: hero.html,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHeroSolutions(): Promise<HeroSolution[]> {
|
||||
let solutions: (HeroSolution & { order?: number })[] = []
|
||||
try {
|
||||
const files = await readMarkdownDirectory('solutions', { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
solutions = files.map((file) => ({
|
||||
slug: file.slug,
|
||||
title: ensureString(file.metadata.title) ?? file.slug,
|
||||
tagline: ensureString(file.metadata.tagline),
|
||||
description: ensureString(file.metadata.description),
|
||||
features: ensureStringArray(file.metadata.features),
|
||||
bodyHtml: file.html,
|
||||
primaryCtaLabel: ensureString(file.metadata.primaryCtaLabel),
|
||||
primaryCtaHref: ensureString(file.metadata.primaryCtaHref),
|
||||
secondaryCtaLabel: ensureString(file.metadata.secondaryCtaLabel),
|
||||
secondaryCtaHref: ensureString(file.metadata.secondaryCtaHref),
|
||||
order: ensureNumber(file.metadata.order),
|
||||
}))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return solutions
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order
|
||||
}
|
||||
if (a.order !== undefined) return -1
|
||||
if (b.order !== undefined) return 1
|
||||
return a.title.localeCompare(b.title, 'zh-CN')
|
||||
})
|
||||
.map(({ order: _order, ...solution }) => solution)
|
||||
}
|
||||
|
||||
export async function getHomepagePosts(): Promise<HomepagePost[]> {
|
||||
const postsDir = path.join('posts')
|
||||
const posts = await readMarkdownDirectory(postsDir, { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
|
||||
const enriched = posts.map((post) => {
|
||||
const title = ensureString(post.metadata.title) ?? post.slug
|
||||
const author = ensureString(post.metadata.author)
|
||||
const date = ensureString(post.metadata.date)
|
||||
const readingTime = ensureString(post.metadata.readingTime)
|
||||
const tags = ensureStringArray(post.metadata.tags)
|
||||
const excerptMetadata = ensureString(post.metadata.excerpt)
|
||||
const excerpt = excerptMetadata ?? extractExcerpt(post.content)
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
title,
|
||||
author,
|
||||
date,
|
||||
readingTime,
|
||||
tags,
|
||||
excerpt,
|
||||
contentHtml: post.html,
|
||||
}
|
||||
})
|
||||
|
||||
const withParsedDates = enriched.map((post) => ({
|
||||
...post,
|
||||
dateValue: post.date ? new Date(post.date) : undefined,
|
||||
}))
|
||||
|
||||
withParsedDates.sort((a, b) => {
|
||||
if (a.dateValue && b.dateValue) {
|
||||
return b.dateValue.getTime() - a.dateValue.getTime()
|
||||
}
|
||||
if (a.dateValue) return -1
|
||||
if (b.dateValue) return 1
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
return withParsedDates.map(({ dateValue: _dateValue, ...post }) => post)
|
||||
}
|
||||
|
||||
export async function getSidebarSections(): Promise<SidebarSection[]> {
|
||||
const sidebarDir = path.join('sidebar')
|
||||
let sections: SidebarSection[] = []
|
||||
try {
|
||||
const files = await readMarkdownDirectory(sidebarDir, { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
sections = files.map((file) => ({
|
||||
slug: file.slug,
|
||||
title: ensureString(file.metadata.title) ?? file.slug,
|
||||
layout: ensureString(file.metadata.layout),
|
||||
tags: ensureStringArray(file.metadata.tags),
|
||||
bodyHtml: file.html,
|
||||
ctaLabel: ensureString(file.metadata.ctaLabel),
|
||||
ctaHref: ensureString(file.metadata.ctaHref),
|
||||
order: ensureNumber(file.metadata.order),
|
||||
}))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order
|
||||
}
|
||||
if (a.order !== undefined) return -1
|
||||
if (b.order !== undefined) return 1
|
||||
return a.title.localeCompare(b.title, 'zh-CN')
|
||||
})
|
||||
.map(({ order: _order, ...section }) => section)
|
||||
}
|
||||
|
||||
export async function getContactPanelContent(): Promise<ContactPanelContent | undefined> {
|
||||
let panelFile
|
||||
try {
|
||||
panelFile = await readMarkdownFile(path.join('contact', 'panel.md'), { baseDir: HOMEPAGE_CONTENT_ROOT })
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let items: (ContactItemContent & { order?: number })[] = []
|
||||
try {
|
||||
const itemFiles = await readMarkdownDirectory(path.join('contact', 'items'), {
|
||||
baseDir: HOMEPAGE_CONTENT_ROOT,
|
||||
})
|
||||
items = itemFiles.map((file) => ({
|
||||
slug: file.slug,
|
||||
title: ensureString(file.metadata.title) ?? file.slug,
|
||||
type: ensureString(file.metadata.type),
|
||||
description: ensureString(file.metadata.description),
|
||||
bodyHtml: file.html,
|
||||
qrValue: ensureString(file.metadata.qrValue),
|
||||
icon: ensureString(file.metadata.icon),
|
||||
ctaLabel: ensureString(file.metadata.ctaLabel),
|
||||
ctaHref: ensureString(file.metadata.ctaHref),
|
||||
order: ensureNumber(file.metadata.order),
|
||||
}))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const sortedItems = items
|
||||
.sort((a, b) => {
|
||||
if (a.order !== undefined && b.order !== undefined) {
|
||||
return a.order - b.order
|
||||
}
|
||||
if (a.order !== undefined) return -1
|
||||
if (b.order !== undefined) return 1
|
||||
return a.title.localeCompare(b.title, 'zh-CN')
|
||||
})
|
||||
.map(({ order: _order, ...item }) => item)
|
||||
|
||||
return {
|
||||
title: ensureString(panelFile.metadata.title) ?? '保持联系',
|
||||
subtitle: ensureString(panelFile.metadata.subtitle),
|
||||
bodyHtml: panelFile.html,
|
||||
items: sortedItems,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContentLastUpdated(): Promise<string | undefined> {
|
||||
try {
|
||||
const stats = await fs.stat(path.join(HOMEPAGE_CONTENT_ROOT, 'hero.md'))
|
||||
return stats.mtime.toISOString()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
108
ui/homepage/lib/markdown.ts
Normal file
108
ui/homepage/lib/markdown.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { marked } from 'marked'
|
||||
|
||||
export type FrontMatterValue = string | string[]
|
||||
|
||||
export interface MarkdownFile {
|
||||
metadata: Record<string, FrontMatterValue>
|
||||
content: string
|
||||
html: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
const CONTENT_ROOT = path.join(process.cwd(), 'content')
|
||||
|
||||
function normalizeQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
if (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
||||
) {
|
||||
return trimmed.slice(1, -1).trim()
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function parseFrontMatter(raw: string): {
|
||||
metadata: Record<string, FrontMatterValue>
|
||||
content: string
|
||||
} {
|
||||
const frontMatterMatch = raw.match(/^---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/)
|
||||
if (!frontMatterMatch) {
|
||||
return { metadata: {}, content: raw.trim() }
|
||||
}
|
||||
|
||||
const [, frontMatter, body] = frontMatterMatch
|
||||
const metadata: Record<string, FrontMatterValue> = {}
|
||||
let currentKey: string | null = null
|
||||
|
||||
const lines = frontMatter.split(/\r?\n/)
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const keyValueMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
|
||||
if (keyValueMatch) {
|
||||
const [, key, value] = keyValueMatch
|
||||
currentKey = key
|
||||
if (!value || value.trim() === '') {
|
||||
metadata[key] = []
|
||||
continue
|
||||
}
|
||||
|
||||
metadata[key] = normalizeQuotes(value)
|
||||
continue
|
||||
}
|
||||
|
||||
if (currentKey && line.trim().startsWith('- ')) {
|
||||
const normalizedValue = normalizeQuotes(line.trim().slice(2))
|
||||
const currentValue = metadata[currentKey]
|
||||
if (Array.isArray(currentValue)) {
|
||||
currentValue.push(normalizedValue)
|
||||
} else if (typeof currentValue === 'string') {
|
||||
metadata[currentKey] = [currentValue, normalizedValue]
|
||||
} else {
|
||||
metadata[currentKey] = [normalizedValue]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metadata,
|
||||
content: body.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function readMarkdownFile(
|
||||
relativePath: string,
|
||||
options?: { baseDir?: string }
|
||||
): Promise<MarkdownFile> {
|
||||
const baseDir = options?.baseDir ?? CONTENT_ROOT
|
||||
const filePath = path.join(baseDir, relativePath)
|
||||
const raw = await fs.readFile(filePath, 'utf-8')
|
||||
const { metadata, content } = parseFrontMatter(raw)
|
||||
const htmlResult = await marked.parse(content)
|
||||
const html = typeof htmlResult === 'string' ? htmlResult : await htmlResult
|
||||
const slug = path.basename(relativePath, path.extname(relativePath))
|
||||
|
||||
return { metadata, content, html, slug }
|
||||
}
|
||||
|
||||
export async function readMarkdownDirectory(
|
||||
relativeDir: string,
|
||||
options?: { baseDir?: string }
|
||||
): Promise<MarkdownFile[]> {
|
||||
const baseDir = options?.baseDir ?? CONTENT_ROOT
|
||||
const dirPath = path.join(baseDir, relativeDir)
|
||||
const entries = await fs.readdir(dirPath, { withFileTypes: true })
|
||||
|
||||
const files = entries.filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
|
||||
|
||||
const results = await Promise.all(
|
||||
files.map((file) => readMarkdownFile(path.join(relativeDir, file.name), { baseDir }))
|
||||
)
|
||||
|
||||
return results
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user