feat(dashboard): add marketing product pages (#649)
This commit is contained in:
parent
21d7f40fc5
commit
895d5727b5
190
dashboard/app/[product]/Client.tsx
Normal file
190
dashboard/app/[product]/Client.tsx
Normal file
@ -0,0 +1,190 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import html2canvas from 'html2canvas'
|
||||
type QRCodeToCanvas = (
|
||||
canvas: HTMLCanvasElement,
|
||||
text: string,
|
||||
options?: Record<string, unknown>
|
||||
) => Promise<unknown>
|
||||
|
||||
import ProductCommunity from '@components/marketing/ProductCommunity'
|
||||
import ProductDocs from '@components/marketing/ProductDocs'
|
||||
import ProductDownload from '@components/marketing/ProductDownload'
|
||||
import ProductEditions from '@components/marketing/ProductEditions'
|
||||
import ProductFaq from '@components/marketing/ProductFaq'
|
||||
import ProductFeatures from '@components/marketing/ProductFeatures'
|
||||
import ProductHero from '@components/marketing/ProductHero'
|
||||
import ProductScenarios from '@components/marketing/ProductScenarios'
|
||||
import type { ProductConfig } from '@src/products/registry'
|
||||
|
||||
export type Lang = 'zh' | 'en'
|
||||
|
||||
const drawQR = async (
|
||||
node: HTMLElement | null,
|
||||
url: string,
|
||||
size: number = 180
|
||||
) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
node.innerHTML = ''
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
|
||||
try {
|
||||
const { toCanvas } = (await import('qrcode')) as unknown as {
|
||||
toCanvas: QRCodeToCanvas
|
||||
}
|
||||
|
||||
await toCanvas(canvas, url, {
|
||||
width: size,
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'H',
|
||||
})
|
||||
node.appendChild(canvas)
|
||||
} catch (error) {
|
||||
console.error('Failed to render QR code', error)
|
||||
}
|
||||
}
|
||||
|
||||
const exportPoster = async (node: HTMLElement | null, slug: string) => {
|
||||
if (!node) {
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = await html2canvas(node, {
|
||||
backgroundColor: '#ffffff',
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
})
|
||||
|
||||
const link = document.createElement('a')
|
||||
const date = new Date().toISOString().slice(0, 10)
|
||||
link.download = `${slug}-poster-${date}.png`
|
||||
link.href = canvas.toDataURL('image/png')
|
||||
link.click()
|
||||
}
|
||||
|
||||
type ClientProps = {
|
||||
config: ProductConfig
|
||||
}
|
||||
|
||||
export default function Client({ config }: ClientProps) {
|
||||
const [lang, setLang] = useState<Lang>('zh')
|
||||
const defaultQrUrl = useMemo(
|
||||
() => `https://www.svc.plus/${config.slug}/`,
|
||||
[config.slug]
|
||||
)
|
||||
const [qrUrl, setQrUrl] = useState(defaultQrUrl)
|
||||
|
||||
const qrRef = useRef<HTMLDivElement>(null)
|
||||
const posterRef = useRef<HTMLDivElement>(null)
|
||||
const posterQrRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const preferred = navigator.language?.toLowerCase().startsWith('en')
|
||||
? 'en'
|
||||
: 'zh'
|
||||
setLang(preferred)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setQrUrl(defaultQrUrl)
|
||||
}, [defaultQrUrl])
|
||||
|
||||
useEffect(() => {
|
||||
drawQR(qrRef.current, qrUrl, 180)
|
||||
drawQR(posterQrRef.current, qrUrl, 220)
|
||||
}, [qrUrl])
|
||||
|
||||
const handleToggleLanguage = useCallback(() => {
|
||||
setLang((current) => (current === 'zh' ? 'en' : 'zh'))
|
||||
}, [])
|
||||
|
||||
const handleExportPoster = useCallback(() => {
|
||||
return exportPoster(posterRef.current, config.slug)
|
||||
}, [config.slug])
|
||||
|
||||
const handleQrUpdate = useCallback(
|
||||
(value: string) => {
|
||||
setQrUrl(value || defaultQrUrl)
|
||||
},
|
||||
[defaultQrUrl]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white text-slate-900">
|
||||
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<Link href="/" className="flex items-center gap-2 text-sm font-semibold">
|
||||
<span className="text-slate-500">SVC.plus</span>
|
||||
<span className="text-slate-400">/</span>
|
||||
<span className="text-brand-dark">{config.name}</span>
|
||||
</Link>
|
||||
<nav className="hidden gap-6 text-sm font-medium text-slate-600 md:flex">
|
||||
<Link href="#features" scroll>
|
||||
{lang === 'zh' ? '核心功能' : 'Features'}
|
||||
</Link>
|
||||
<Link href="#editions" scroll>
|
||||
{lang === 'zh' ? '版本与部署' : 'Editions'}
|
||||
</Link>
|
||||
<Link href="#scenarios" scroll>
|
||||
{lang === 'zh' ? '应用场景' : 'Scenarios'}
|
||||
</Link>
|
||||
<Link href="#download" scroll>
|
||||
{lang === 'zh' ? '下载' : 'Download'}
|
||||
</Link>
|
||||
<Link href="#docs" scroll>
|
||||
{lang === 'zh' ? '文档' : 'Docs'}
|
||||
</Link>
|
||||
<Link href="#faq" scroll>
|
||||
{lang === 'zh' ? 'FAQ' : 'FAQ'}
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleLanguage}
|
||||
className="rounded-full border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 transition hover:bg-slate-50"
|
||||
aria-label={lang === 'zh' ? 'Switch to English' : '切换到中文'}
|
||||
>
|
||||
{lang === 'zh' ? 'EN' : '中文'}
|
||||
</button>
|
||||
<Link
|
||||
href="#download"
|
||||
scroll
|
||||
className="rounded-lg bg-brand px-4 py-2 text-xs font-semibold text-white shadow-sm transition hover:bg-brand-dark"
|
||||
>
|
||||
{lang === 'zh' ? '立即下载' : 'Download'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<ProductHero config={config} lang={lang} onExportPoster={handleExportPoster} />
|
||||
<ProductFeatures lang={lang} />
|
||||
<ProductEditions config={config} lang={lang} />
|
||||
<ProductScenarios lang={lang} />
|
||||
<ProductDownload config={config} lang={lang} />
|
||||
<ProductDocs config={config} lang={lang} />
|
||||
<ProductFaq lang={lang} />
|
||||
<ProductCommunity
|
||||
config={config}
|
||||
lang={lang}
|
||||
qrUrl={qrUrl}
|
||||
onQrUrlChange={handleQrUpdate}
|
||||
qrRef={qrRef}
|
||||
posterRef={posterRef}
|
||||
posterQrRef={posterQrRef}
|
||||
onExportPoster={handleExportPoster}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
dashboard/app/[product]/page.tsx
Normal file
56
dashboard/app/[product]/page.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
import Client from './Client'
|
||||
import { PRODUCT_MAP, getAllSlugs } from '@src/products/registry'
|
||||
|
||||
type PageProps = {
|
||||
params: {
|
||||
product: string
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return getAllSlugs().map((slug) => ({ product: slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const config = PRODUCT_MAP.get(params.product)
|
||||
|
||||
if (!config) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const description = `${config.name} — ${config.tagline_en}`
|
||||
const canonical = `https://www.svc.plus/${config.slug}`
|
||||
|
||||
return {
|
||||
title: config.title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical,
|
||||
},
|
||||
openGraph: {
|
||||
title: config.title_en,
|
||||
description,
|
||||
images: [config.ogImage],
|
||||
url: canonical,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: config.title_en,
|
||||
description,
|
||||
images: [config.ogImage],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function ProductPage({ params }: PageProps) {
|
||||
const config = PRODUCT_MAP.get(params.product)
|
||||
|
||||
if (!config) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return <Client config={config} />
|
||||
}
|
||||
168
dashboard/components/marketing/ProductCommunity.tsx
Normal file
168
dashboard/components/marketing/ProductCommunity.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { Github, MessageCircle, Newspaper, PlayCircle, QrCode } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
import type { ProductConfig } from '@src/products/registry'
|
||||
|
||||
type ProductCommunityProps = {
|
||||
config: ProductConfig
|
||||
lang: 'zh' | 'en'
|
||||
qrUrl: string
|
||||
onQrUrlChange: (value: string) => void
|
||||
qrRef: React.RefObject<HTMLDivElement>
|
||||
posterRef: React.RefObject<HTMLDivElement>
|
||||
posterQrRef: React.RefObject<HTMLDivElement>
|
||||
onExportPoster: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const Poster = forwardRef<HTMLDivElement, { config: ProductConfig; lang: 'zh' | 'en'; posterQrRef: React.RefObject<HTMLDivElement> }>(
|
||||
({ config, lang, posterQrRef }, ref) => {
|
||||
const tagline = lang === 'zh' ? config.tagline_zh : config.tagline_en
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="fixed top-0 left-0 -translate-x-[150vw] w-[720px] max-w-full bg-white p-10 text-slate-900"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div className="flex h-full w-full flex-col justify-between rounded-3xl border border-slate-200 bg-gradient-to-b from-brand-surface to-white p-10 shadow-xl">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 text-xl font-semibold text-slate-900">
|
||||
<QrCode className="h-6 w-6 text-brand-dark" aria-hidden="true" />
|
||||
<span>SVC.plus / {config.name}</span>
|
||||
</div>
|
||||
<h2 className="mt-8 text-5xl font-extrabold text-slate-900">
|
||||
{lang === 'zh' ? config.title : config.title_en}
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-slate-700">{tagline}</p>
|
||||
<ul className="mt-6 space-y-2 text-base text-slate-700">
|
||||
<li>• {lang === 'zh' ? 'Windows / macOS / Linux 全平台支持' : 'Windows / macOS / Linux support'}</li>
|
||||
<li>• {lang === 'zh' ? '官网 / 下载' : 'Website / Downloads'}: https://www.svc.plus/{config.slug}/</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-600">
|
||||
{lang === 'zh' ? '长按识别二维码' : 'Scan the QR code'}
|
||||
</p>
|
||||
<p className="text-2xl font-semibold text-slate-900">
|
||||
{lang === 'zh' ? '立即开始加速' : 'Start accelerating now'}
|
||||
</p>
|
||||
</div>
|
||||
<div ref={posterQrRef} className="rounded-2xl border border-slate-200 bg-white p-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Poster.displayName = 'Poster'
|
||||
|
||||
export default function ProductCommunity({
|
||||
config,
|
||||
lang,
|
||||
qrUrl,
|
||||
onQrUrlChange,
|
||||
qrRef,
|
||||
posterRef,
|
||||
posterQrRef,
|
||||
onExportPoster,
|
||||
}: ProductCommunityProps) {
|
||||
return (
|
||||
<section id="community" aria-labelledby="community-title" className="bg-slate-50 py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<div>
|
||||
<h2 id="community-title" className="text-3xl font-bold text-slate-900">
|
||||
{lang === 'zh' ? '社区与支持' : 'Community & Support'}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-600">
|
||||
{lang === 'zh'
|
||||
? '加入开发者社区,获取教程、博客、视频等资源。'
|
||||
: 'Join the community for tutorials, blog posts, and guided videos.'}
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={config.repoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<Github className="h-4 w-4" aria-hidden="true" />
|
||||
GitHub
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
onClick={onExportPoster}
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{lang === 'zh' ? '分享海报' : 'Share poster'}
|
||||
</button>
|
||||
<Link
|
||||
href={config.blogUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<Newspaper className="h-4 w-4" aria-hidden="true" />
|
||||
{lang === 'zh' ? '官方博客' : 'Official blog'}
|
||||
</Link>
|
||||
<Link
|
||||
href={config.videosUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<PlayCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{lang === 'zh' ? '视频教程' : 'Video tutorials'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{lang === 'zh' ? '扫码体验' : 'Scan to try'}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
{lang === 'zh'
|
||||
? '保存或分享二维码,适用于微信等内置浏览器。'
|
||||
: 'Save or share the QR code—works in embedded browsers like WeChat.'}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-6 sm:flex-row">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
ref={qrRef}
|
||||
className="flex h-[180px] w-[180px] items-center justify-center rounded-xl border border-slate-200 bg-white"
|
||||
aria-label={lang === 'zh' ? '产品二维码' : 'Product QR code'}
|
||||
/>
|
||||
<span className="text-xs text-slate-500">{qrUrl}</span>
|
||||
</div>
|
||||
<div className="flex-1 space-y-3 text-sm text-slate-700">
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||
{lang === 'zh' ? '自定义跳转链接' : 'Custom URL'}
|
||||
</span>
|
||||
<input
|
||||
value={qrUrl}
|
||||
onChange={(event) => onQrUrlChange(event.target.value)}
|
||||
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm focus:border-brand focus:outline-none focus:ring-2 focus:ring-brand-light"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportPoster}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<QrCode className="h-4 w-4" aria-hidden="true" />
|
||||
{lang === 'zh' ? '导出海报' : 'Export poster'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Poster config={config} lang={lang} posterQrRef={posterQrRef} ref={posterRef} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
111
dashboard/components/marketing/ProductDocs.tsx
Normal file
111
dashboard/components/marketing/ProductDocs.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { ProductConfig } from '@src/products/registry'
|
||||
|
||||
type ProductDocsProps = {
|
||||
config: ProductConfig
|
||||
lang: 'zh' | 'en'
|
||||
}
|
||||
|
||||
export default function ProductDocs({ config, lang }: ProductDocsProps) {
|
||||
return (
|
||||
<section id="docs" aria-labelledby="docs-title" className="bg-slate-50 py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<div>
|
||||
<h2 id="docs-title" className="text-3xl font-bold text-slate-900">
|
||||
{lang === 'zh' ? '帮助文档' : 'Documentation'}
|
||||
</h2>
|
||||
<ol className="mt-4 space-y-3 text-sm text-slate-700">
|
||||
<li>
|
||||
1. {lang === 'zh' ? '下载并安装客户端' : 'Download and install the client'}
|
||||
</li>
|
||||
<li>
|
||||
2. {lang === 'zh' ? '注册或登录账户' : 'Sign up or sign in'}
|
||||
</li>
|
||||
<li>
|
||||
3. {lang === 'zh' ? '选择节点或启用自动分配' : 'Choose a node or use auto selection'}
|
||||
</li>
|
||||
<li>
|
||||
4. {lang === 'zh' ? '开始加速并查看监控' : 'Start acceleration with live metrics'}
|
||||
</li>
|
||||
</ol>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={config.docsQuickstart}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{lang === 'zh' ? '快速开始指南' : 'Quickstart'}
|
||||
</Link>
|
||||
<Link
|
||||
href={config.docsIssues}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
GitHub
|
||||
</Link>
|
||||
<Link
|
||||
href={config.videosUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{lang === 'zh' ? '视频教程' : 'Video tutorials'}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{lang === 'zh' ? '文档资源' : 'Resource links'}
|
||||
</h3>
|
||||
<ul className="mt-4 grid gap-3 text-sm text-slate-700 sm:grid-cols-2">
|
||||
<li>
|
||||
<Link
|
||||
href={config.docsQuickstart}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
|
||||
>
|
||||
{lang === 'zh' ? '快速开始' : 'Quickstart'}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={config.docsApi}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
|
||||
>
|
||||
API
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={config.docsIssues}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
|
||||
>
|
||||
{lang === 'zh' ? '故障排查' : 'Issues'}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href={config.blogUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="block rounded-lg border border-slate-200 px-3 py-2 hover:bg-slate-50"
|
||||
>
|
||||
{lang === 'zh' ? '官方博客' : 'Official blog'}
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
101
dashboard/components/marketing/ProductDownload.tsx
Normal file
101
dashboard/components/marketing/ProductDownload.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import Link from 'next/link'
|
||||
import { Laptop, Monitor, Server } from 'lucide-react'
|
||||
|
||||
import type { ProductConfig } from '@src/products/registry'
|
||||
|
||||
type ProductDownloadProps = {
|
||||
config: ProductConfig
|
||||
lang: 'zh' | 'en'
|
||||
}
|
||||
|
||||
const PLATFORMS = {
|
||||
zh: [
|
||||
{
|
||||
key: 'windows',
|
||||
title: 'Windows',
|
||||
subtitle: 'Win10/11 · x64 / ARM64',
|
||||
icon: Monitor,
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
title: 'macOS',
|
||||
subtitle: 'macOS 12+ · Intel / Apple Silicon',
|
||||
icon: Laptop,
|
||||
},
|
||||
{
|
||||
key: 'linux',
|
||||
title: 'Linux',
|
||||
subtitle: 'Ubuntu / Debian / RHEL · x64 / ARM64',
|
||||
icon: Server,
|
||||
},
|
||||
],
|
||||
en: [
|
||||
{
|
||||
key: 'windows',
|
||||
title: 'Windows',
|
||||
subtitle: 'Windows 10/11 · x64 / ARM64',
|
||||
icon: Monitor,
|
||||
},
|
||||
{
|
||||
key: 'mac',
|
||||
title: 'macOS',
|
||||
subtitle: 'macOS 12+ · Intel / Apple Silicon',
|
||||
icon: Laptop,
|
||||
},
|
||||
{
|
||||
key: 'linux',
|
||||
title: 'Linux',
|
||||
subtitle: 'Ubuntu / Debian / RHEL · x64 / ARM64',
|
||||
icon: Server,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function ProductDownload({ config, lang }: ProductDownloadProps) {
|
||||
const items = PLATFORMS[lang]
|
||||
|
||||
return (
|
||||
<section id="download" aria-labelledby="download-title" className="py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<header className="max-w-2xl">
|
||||
<h2 id="download-title" className="text-3xl font-bold text-slate-900">
|
||||
{lang === 'zh' ? '下载' : 'Download'}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-600">
|
||||
{lang === 'zh'
|
||||
? '支持 Windows / macOS / Linux,安装前请核验校验值。'
|
||||
: 'Windows, macOS, and Linux builds. Verify checksums before install.'}
|
||||
</p>
|
||||
</header>
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-3">
|
||||
{items.map(({ key, title, subtitle, icon: Icon }) => (
|
||||
<article key={key} className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3 text-slate-900">
|
||||
<Icon className="h-5 w-5 text-brand-dark" aria-hidden="true" />
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-600">{subtitle}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href={config.downloadUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-brand-dark"
|
||||
>
|
||||
{lang === 'zh' ? '下载' : 'Get'}
|
||||
</Link>
|
||||
<Link
|
||||
href="#docs"
|
||||
scroll
|
||||
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
SHA256
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
78
dashboard/components/marketing/ProductEditions.tsx
Normal file
78
dashboard/components/marketing/ProductEditions.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import Link from 'next/link'
|
||||
|
||||
import type { ProductConfig } from '@src/products/registry'
|
||||
|
||||
type ProductEditionsProps = {
|
||||
config: ProductConfig
|
||||
lang: 'zh' | 'en'
|
||||
}
|
||||
|
||||
const SECTION_COPY = {
|
||||
zh: {
|
||||
title: '版本与部署',
|
||||
description: '开源可扩展,可自建或选择官方托管服务。',
|
||||
actions: {
|
||||
selfhost: '自建(Self-Hosted)',
|
||||
managed: '托管(Managed)',
|
||||
paygo: '按量计费(Pay-as-you-go)',
|
||||
saas: '订阅 SaaS',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
title: 'Editions & Deployment',
|
||||
description: 'Open source and extensible—self-host or leverage managed offerings.',
|
||||
actions: {
|
||||
selfhost: 'Self-Hosted',
|
||||
managed: 'Managed',
|
||||
paygo: 'Pay-as-you-go',
|
||||
saas: 'SaaS Subscription',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type EditionKey = keyof ProductConfig['editions']
|
||||
|
||||
export default function ProductEditions({ config, lang }: ProductEditionsProps) {
|
||||
const copy = SECTION_COPY[lang]
|
||||
const order: EditionKey[] = ['selfhost', 'managed', 'paygo', 'saas']
|
||||
|
||||
return (
|
||||
<section id="editions" aria-labelledby="editions-title" className="bg-white py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<header className="max-w-2xl">
|
||||
<h2 id="editions-title" className="text-3xl font-bold text-slate-900">
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-600">{copy.description}</p>
|
||||
</header>
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{order.map((key) => (
|
||||
<article
|
||||
key={key}
|
||||
className="flex h-full flex-col rounded-2xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-slate-900">
|
||||
{copy.actions[key]}
|
||||
</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm text-slate-600">
|
||||
{config.editions[key].map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link
|
||||
href={link.href}
|
||||
target={link.external ? '_blank' : undefined}
|
||||
rel={link.external ? 'noreferrer' : undefined}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-2 font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{link.label}
|
||||
<span aria-hidden="true">→</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
69
dashboard/components/marketing/ProductFaq.tsx
Normal file
69
dashboard/components/marketing/ProductFaq.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
type ProductFaqProps = {
|
||||
lang: 'zh' | 'en'
|
||||
}
|
||||
|
||||
const FAQ_CONTENT = {
|
||||
zh: [
|
||||
{
|
||||
question: '是否完全开源?支持哪些商业化形态?',
|
||||
answer: '核心代码开源,并提供自建、托管、按量与订阅 SaaS 多种形态。',
|
||||
},
|
||||
{
|
||||
question: '是否支持多设备与多平台?',
|
||||
answer: '支持 Windows / macOS / Linux,多设备可同时登录与切换。',
|
||||
},
|
||||
{
|
||||
question: '是否内置观测与限流保护?',
|
||||
answer: '客户端与边缘节点具备观测、流量控制及异常防护能力。',
|
||||
},
|
||||
{
|
||||
question: '如何参与贡献?',
|
||||
answer: '欢迎在 GitHub 仓库提交 Issue / PR,并加入社区讨论。',
|
||||
},
|
||||
],
|
||||
en: [
|
||||
{
|
||||
question: 'Is it fully open source? What commercial models are available?',
|
||||
answer: 'The core is open source with self-hosted, managed, pay-as-you-go, and SaaS plans.',
|
||||
},
|
||||
{
|
||||
question: 'Does it support multiple devices and platforms?',
|
||||
answer: 'Yes. Windows, macOS, and Linux with seamless device switching.',
|
||||
},
|
||||
{
|
||||
question: 'Are observability and rate limiting built in?',
|
||||
answer: 'Clients and edge nodes ship with telemetry, throttling, and protections.',
|
||||
},
|
||||
{
|
||||
question: 'How can I contribute?',
|
||||
answer: 'Open GitHub issues or pull requests and join the community discussions.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function ProductFaq({ lang }: ProductFaqProps) {
|
||||
const items = FAQ_CONTENT[lang]
|
||||
|
||||
return (
|
||||
<section id="faq" aria-labelledby="faq-title" className="py-16">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8">
|
||||
<h2 id="faq-title" className="text-3xl font-bold text-slate-900">
|
||||
FAQ
|
||||
</h2>
|
||||
<div className="mt-8 space-y-4">
|
||||
{items.map(({ question, answer }) => (
|
||||
<details key={question} className="group rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<summary className="flex cursor-pointer items-center justify-between text-left text-base font-semibold text-slate-900">
|
||||
{question}
|
||||
<ChevronDown className="h-5 w-5 text-slate-400 transition group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<p className="mt-3 text-sm text-slate-600">{answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
85
dashboard/components/marketing/ProductFeatures.tsx
Normal file
85
dashboard/components/marketing/ProductFeatures.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { Activity, Brain, Rocket, Shield } from 'lucide-react'
|
||||
|
||||
type ProductFeaturesProps = {
|
||||
lang: 'zh' | 'en'
|
||||
}
|
||||
|
||||
const FEATURES = {
|
||||
zh: [
|
||||
{
|
||||
title: '极速连接',
|
||||
description: '智能就近接入、跨区域中转,降低首包延迟与抖动。',
|
||||
icon: Rocket,
|
||||
},
|
||||
{
|
||||
title: '安全加密',
|
||||
description: '端到端加密与最小暴露面设计,确保数据安全。',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
title: 'AI 优化',
|
||||
description: '基于实时指标进行路径自适应选择,持续调优。',
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
title: '实时监控',
|
||||
description: '内置观测、告警与审计,掌握全链路健康。',
|
||||
icon: Activity,
|
||||
},
|
||||
],
|
||||
en: [
|
||||
{
|
||||
title: 'Speed',
|
||||
description: 'Smart ingress and inter-region hops reduce latency and jitter.',
|
||||
icon: Rocket,
|
||||
},
|
||||
{
|
||||
title: 'Security',
|
||||
description: 'End-to-end encryption with minimal exposure surfaces.',
|
||||
icon: Shield,
|
||||
},
|
||||
{
|
||||
title: 'AI Optimization',
|
||||
description: 'Adaptive routing powered by live telemetry and policy controls.',
|
||||
icon: Brain,
|
||||
},
|
||||
{
|
||||
title: 'Live Metrics',
|
||||
description: 'Embedded observability, alerting, and auditing end to end.',
|
||||
icon: Activity,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function ProductFeatures({ lang }: ProductFeaturesProps) {
|
||||
const items = FEATURES[lang]
|
||||
|
||||
return (
|
||||
<section id="features" aria-labelledby="features-title" className="py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<header className="max-w-2xl">
|
||||
<h2 id="features-title" className="text-3xl font-bold text-slate-900">
|
||||
{lang === 'zh' ? '核心功能' : 'Core Features'}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-600">
|
||||
{lang === 'zh'
|
||||
? '为稳定、低延迟的全球访问而生。'
|
||||
: 'Built for stable, low-latency global access.'}
|
||||
</p>
|
||||
</header>
|
||||
<div className="mt-10 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{items.map(({ title, description, icon: Icon }) => (
|
||||
<article
|
||||
key={title}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm transition hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<Icon className="h-6 w-6 text-brand-dark" aria-hidden="true" />
|
||||
<h3 className="mt-4 text-lg font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-600">{description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
87
dashboard/components/marketing/ProductHero.tsx
Normal file
87
dashboard/components/marketing/ProductHero.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { BookOpen, Download, QrCode } from 'lucide-react'
|
||||
|
||||
import type { ProductConfig } from '@src/products/registry'
|
||||
|
||||
type ProductHeroProps = {
|
||||
config: ProductConfig
|
||||
lang: 'zh' | 'en'
|
||||
onExportPoster: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export default function ProductHero({ config, lang, onExportPoster }: ProductHeroProps) {
|
||||
const tagline = lang === 'zh' ? config.tagline_zh : config.tagline_en
|
||||
const primaryCta = lang === 'zh' ? '下载客户端' : 'Get the App'
|
||||
const secondaryCta = lang === 'zh' ? '快速开始' : 'Quickstart'
|
||||
const posterCta = lang === 'zh' ? '生成推广海报' : 'Create Poster'
|
||||
const badges =
|
||||
lang === 'zh'
|
||||
? ['开源', '自建', '托管', '按量计费', '订阅 SaaS']
|
||||
: ['Open Source', 'Self-Hosted', 'Managed', 'Pay-as-you-go', 'SaaS']
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-brand-surface/70 to-white" aria-labelledby="product-hero">
|
||||
<div className="absolute inset-0 pointer-events-none bg-[radial-gradient(circle,rgba(44,137,255,0.08)_1px,transparent_1px)] [background-size:24px_24px]" />
|
||||
<div className="relative mx-auto flex max-w-6xl flex-col gap-12 px-4 py-16 sm:px-6 lg:flex-row lg:items-center lg:py-20">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-wide text-brand-dark">
|
||||
{config.name}
|
||||
</p>
|
||||
<h1 id="product-hero" className="mt-4 text-4xl font-extrabold tracking-tight text-slate-900 sm:text-5xl">
|
||||
{lang === 'zh' ? config.title : config.title_en}
|
||||
</h1>
|
||||
<p className="mt-5 text-lg text-slate-600">{tagline}</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="#download"
|
||||
scroll
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-brand px-5 py-3 text-sm font-semibold text-white shadow transition hover:bg-brand-dark"
|
||||
>
|
||||
<Download className="h-4 w-4" aria-hidden="true" />
|
||||
{primaryCta}
|
||||
</Link>
|
||||
<Link
|
||||
href="#docs"
|
||||
scroll
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
{secondaryCta}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportPoster}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
<QrCode className="h-4 w-4" aria-hidden="true" />
|
||||
{posterCta}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="mt-6 flex flex-wrap gap-2 text-xs text-slate-600">
|
||||
{badges.map((badge) => (
|
||||
<li key={badge} className="rounded-full border border-slate-200 bg-white/70 px-3 py-1">
|
||||
{badge}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative flex flex-1 justify-center lg:justify-end">
|
||||
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-2xl">
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1526498460520-4c246339dccb?q=80&w=1600&auto=format&fit=crop"
|
||||
alt={lang === 'zh' ? `${config.name} 网络示意图` : `${config.name} network illustration`}
|
||||
width={720}
|
||||
height={480}
|
||||
className="h-full w-full object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute bottom-4 left-4 rounded-lg border border-slate-200 bg-white/80 px-4 py-2 text-xs font-medium text-slate-600 backdrop-blur">
|
||||
{lang === 'zh' ? 'AI 路由优化开启中…' : 'AI route optimization active…'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
84
dashboard/components/marketing/ProductScenarios.tsx
Normal file
84
dashboard/components/marketing/ProductScenarios.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
type ProductScenariosProps = {
|
||||
lang: 'zh' | 'en'
|
||||
}
|
||||
|
||||
const SCENARIOS = {
|
||||
zh: [
|
||||
{
|
||||
title: 'AI 模型 / DevTools 加速',
|
||||
description: '保障 ChatGPT、Gemini、Claude 等模型服务顺畅访问。',
|
||||
},
|
||||
{
|
||||
title: 'GitHub / 镜像分发',
|
||||
description: '加速代码克隆、Release 下载与容器镜像同步。',
|
||||
},
|
||||
{
|
||||
title: '跨境办公 / 媒体服务',
|
||||
description: '优化海外购物、视频、音乐等站点体验。',
|
||||
},
|
||||
],
|
||||
en: [
|
||||
{
|
||||
title: 'AI APIs & Dev Tools',
|
||||
description: 'Keep ChatGPT, Gemini, Claude, and dependency downloads flowing.',
|
||||
},
|
||||
{
|
||||
title: 'GitHub & Registry Sync',
|
||||
description: 'Accelerate git clones, release artifacts, and container pulls.',
|
||||
},
|
||||
{
|
||||
title: 'Cross-border Work & Media',
|
||||
description: 'Improve shopping, video, and media experiences worldwide.',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export default function ProductScenarios({ lang }: ProductScenariosProps) {
|
||||
const items = SCENARIOS[lang]
|
||||
|
||||
return (
|
||||
<section id="scenarios" aria-labelledby="scenarios-title" className="bg-slate-50 py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||
<header className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 id="scenarios-title" className="text-3xl font-bold text-slate-900">
|
||||
{lang === 'zh' ? '典型应用场景' : 'Typical Use Cases'}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-600">
|
||||
{lang === 'zh'
|
||||
? '聚焦开发者常用服务与日常访问,游戏等超低延迟场景不做覆盖。'
|
||||
: 'Focused on developer services and daily access—not tuned for ultra-low-latency gaming.'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="#docs"
|
||||
scroll
|
||||
className="inline-flex items-center gap-2 text-sm font-semibold text-brand-dark hover:underline"
|
||||
>
|
||||
{lang === 'zh' ? '查看使用说明' : 'Read the guide'}
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</header>
|
||||
<div className="mt-10 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map(({ title, description }) => (
|
||||
<article key={title} className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-3 text-sm text-slate-600">{description}</p>
|
||||
<Link
|
||||
href="#docs"
|
||||
scroll
|
||||
className="mt-4 inline-flex items-center gap-1 text-sm font-medium text-brand-dark hover:underline"
|
||||
>
|
||||
{lang === 'zh' ? '了解详情' : 'Learn more'}
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@ -12,6 +12,14 @@ const nextConfig = {
|
||||
protocol: 'https',
|
||||
hostname: 'dl.svc.plus',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'www.svc.plus',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@ -51,4 +59,29 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export async function redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/XStream',
|
||||
destination: '/xstream',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/Xstream',
|
||||
destination: '/xstream',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/XScopeHub',
|
||||
destination: '/xscopehub',
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: '/XCloudFlow',
|
||||
destination: '/xcloudflow',
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@ -12,6 +12,9 @@
|
||||
"build:static": "npm run prebuild && next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write .",
|
||||
"preview": "next build && next start",
|
||||
"test": "vitest run",
|
||||
"test:unit": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
@ -19,6 +22,7 @@
|
||||
"dependencies": {
|
||||
"dompurify": "^3.2.6",
|
||||
"gray-matter": "^4.0.3",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lucide-react": "^0.319.0",
|
||||
"marked": "^16.1.2",
|
||||
@ -53,6 +57,7 @@
|
||||
"eslint-config-next": "^15.5.3",
|
||||
"jsdom": "^24.0.0",
|
||||
"postcss": "^8.4.32",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tsconfig-paths-webpack-plugin": "^4.2.0",
|
||||
"tsx": "^4.7.1",
|
||||
|
||||
40
dashboard/src/products/registry.test.ts
Normal file
40
dashboard/src/products/registry.test.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { PRODUCT_LIST, PRODUCT_MAP, getAllSlugs } from './registry'
|
||||
|
||||
describe('product registry', () => {
|
||||
it('exposes at least one product', () => {
|
||||
expect(PRODUCT_LIST.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('contains unique slugs', () => {
|
||||
const slugs = getAllSlugs()
|
||||
const unique = new Set(slugs)
|
||||
expect(unique.size).toBe(slugs.length)
|
||||
})
|
||||
|
||||
it('maps slugs to configs', () => {
|
||||
for (const product of PRODUCT_LIST) {
|
||||
expect(PRODUCT_MAP.get(product.slug)).toBe(product)
|
||||
}
|
||||
})
|
||||
|
||||
it('provides expected config shape', () => {
|
||||
for (const product of PRODUCT_LIST) {
|
||||
expect(product.slug).toMatch(/^[a-z0-9-]+$/)
|
||||
expect(product.name).toBeTruthy()
|
||||
expect(product.title).toBeTruthy()
|
||||
expect(product.title_en).toBeTruthy()
|
||||
expect(product.tagline_zh).toBeTruthy()
|
||||
expect(product.tagline_en).toBeTruthy()
|
||||
expect(product.ogImage).toContain('http')
|
||||
expect(product.repoUrl).toContain('http')
|
||||
expect(product.docsQuickstart).toContain('http')
|
||||
expect(product.docsApi).toContain('http')
|
||||
expect(product.docsIssues).toContain('http')
|
||||
expect(product.blogUrl).toContain('http')
|
||||
expect(product.videosUrl).toContain('http')
|
||||
expect(product.downloadUrl).toContain('http')
|
||||
}
|
||||
})
|
||||
})
|
||||
42
dashboard/src/products/registry.ts
Normal file
42
dashboard/src/products/registry.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import xcloudflow from './xcloudflow'
|
||||
import xscopehub from './xscopehub'
|
||||
import xstream from './xstream'
|
||||
|
||||
export type EditionLink = {
|
||||
label: string
|
||||
href: string
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export type Editions = {
|
||||
selfhost: EditionLink[]
|
||||
managed: EditionLink[]
|
||||
paygo: EditionLink[]
|
||||
saas: EditionLink[]
|
||||
}
|
||||
|
||||
export type ProductConfig = {
|
||||
slug: string
|
||||
name: string
|
||||
title: string
|
||||
title_en: string
|
||||
tagline_zh: string
|
||||
tagline_en: string
|
||||
ogImage: string
|
||||
repoUrl: string
|
||||
docsQuickstart: string
|
||||
docsApi: string
|
||||
docsIssues: string
|
||||
blogUrl: string
|
||||
videosUrl: string
|
||||
downloadUrl: string
|
||||
editions: Editions
|
||||
}
|
||||
|
||||
export const PRODUCT_LIST: ProductConfig[] = [xstream, xscopehub, xcloudflow]
|
||||
|
||||
export const PRODUCT_MAP = new Map<string, ProductConfig>(
|
||||
PRODUCT_LIST.map((product) => [product.slug, product])
|
||||
)
|
||||
|
||||
export const getAllSlugs = (): string[] => PRODUCT_LIST.map((product) => product.slug)
|
||||
55
dashboard/src/products/xcloudflow.ts
Normal file
55
dashboard/src/products/xcloudflow.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { ProductConfig } from './registry'
|
||||
|
||||
const xcloudflow: ProductConfig = {
|
||||
slug: 'xcloudflow',
|
||||
name: 'XCloudFlow',
|
||||
title: 'XCloudFlow — 多云工作流与自动化平台',
|
||||
title_en: 'XCloudFlow — Multi-cloud Workflow Automation',
|
||||
tagline_zh: '统一调度跨云资源,内置 AI 协作与合规审计。',
|
||||
tagline_en: 'Coordinate multi-cloud workloads with AI assistance and governance built in.',
|
||||
ogImage: 'https://www.svc.plus/assets/og/xcloudflow.png',
|
||||
repoUrl: 'https://github.com/Cloud-Neutral/XCloudFlow',
|
||||
docsQuickstart: 'https://www.svc.plus/xcloudflow/docs/quickstart',
|
||||
docsApi: 'https://www.svc.plus/xcloudflow/docs/api',
|
||||
docsIssues: 'https://github.com/Cloud-Neutral/XCloudFlow/issues',
|
||||
blogUrl: 'https://www.svc.plus/blog/tags/xcloudflow',
|
||||
videosUrl: 'https://www.svc.plus/videos/xcloudflow',
|
||||
downloadUrl: 'https://www.svc.plus/xcloudflow/downloads',
|
||||
editions: {
|
||||
selfhost: [
|
||||
{
|
||||
label: 'Terraform 模块',
|
||||
href: 'https://github.com/Cloud-Neutral/XCloudFlow/tree/main/deploy/terraform',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: '离线安装包',
|
||||
href: 'https://www.svc.plus/xcloudflow/downloads',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
managed: [
|
||||
{
|
||||
label: '专业托管',
|
||||
href: 'https://www.svc.plus/contact?product=xcloudflow',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
paygo: [
|
||||
{
|
||||
label: '按量计费',
|
||||
href: 'https://www.svc.plus/pricing/xcloudflow',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
saas: [
|
||||
{
|
||||
label: '团队订阅',
|
||||
href: 'https://www.svc.plus/xcloudflow/signup',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default xcloudflow
|
||||
55
dashboard/src/products/xscopehub.ts
Normal file
55
dashboard/src/products/xscopehub.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { ProductConfig } from './registry'
|
||||
|
||||
const xscopehub: ProductConfig = {
|
||||
slug: 'xscopehub',
|
||||
name: 'XScopeHub',
|
||||
title: 'XScopeHub — 云原生可观测性控制台',
|
||||
title_en: 'XScopeHub — Cloud Observability Hub',
|
||||
tagline_zh: '统一指标、日志、链路追踪,一站式智能告警。',
|
||||
tagline_en: 'Unified metrics, logs, and traces with intelligent alerting in one hub.',
|
||||
ogImage: 'https://www.svc.plus/assets/og/xscopehub.png',
|
||||
repoUrl: 'https://github.com/Cloud-Neutral/XScopeHub',
|
||||
docsQuickstart: 'https://www.svc.plus/xscopehub/docs/quickstart',
|
||||
docsApi: 'https://www.svc.plus/xscopehub/docs/api',
|
||||
docsIssues: 'https://github.com/Cloud-Neutral/XScopeHub/issues',
|
||||
blogUrl: 'https://www.svc.plus/blog/tags/xscopehub',
|
||||
videosUrl: 'https://www.svc.plus/videos/xscopehub',
|
||||
downloadUrl: 'https://www.svc.plus/xscopehub/downloads',
|
||||
editions: {
|
||||
selfhost: [
|
||||
{
|
||||
label: '部署包下载',
|
||||
href: 'https://www.svc.plus/xscopehub/downloads',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: 'Helm Chart',
|
||||
href: 'https://github.com/Cloud-Neutral/XScopeHub/tree/main/deploy/helm',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
managed: [
|
||||
{
|
||||
label: '预约演示',
|
||||
href: 'https://www.svc.plus/contact?product=xscopehub',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
paygo: [
|
||||
{
|
||||
label: '弹性计费',
|
||||
href: 'https://www.svc.plus/pricing/xscopehub',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
saas: [
|
||||
{
|
||||
label: '立即订阅',
|
||||
href: 'https://www.svc.plus/xscopehub/signup',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default xscopehub
|
||||
55
dashboard/src/products/xstream.ts
Normal file
55
dashboard/src/products/xstream.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { ProductConfig } from './registry'
|
||||
|
||||
const xstream: ProductConfig = {
|
||||
slug: 'xstream',
|
||||
name: 'Xstream',
|
||||
title: 'Xstream — 全球网络加速器',
|
||||
title_en: 'Xstream — Global Network Accelerator',
|
||||
tagline_zh: '极速连接|安全加密|AI 路径优化|实时监控。',
|
||||
tagline_en: 'Fast connect | Secure encryption | AI path optimization | Live metrics.',
|
||||
ogImage: 'https://www.svc.plus/assets/og/xstream.png',
|
||||
repoUrl: 'https://github.com/Cloud-Neutral/Xstream',
|
||||
docsQuickstart: 'https://github.com/Cloud-Neutral/Xstream#readme',
|
||||
docsApi: 'https://github.com/Cloud-Neutral/Xstream/tree/main/docs',
|
||||
docsIssues: 'https://github.com/Cloud-Neutral/Xstream/issues',
|
||||
blogUrl: 'https://www.svc.plus/blog',
|
||||
videosUrl: 'https://www.svc.plus/videos',
|
||||
downloadUrl: 'https://github.com/Cloud-Neutral/Xstream/releases',
|
||||
editions: {
|
||||
selfhost: [
|
||||
{
|
||||
label: 'GitHub 仓库',
|
||||
href: 'https://github.com/Cloud-Neutral/Xstream',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
label: '部署指南',
|
||||
href: 'https://github.com/Cloud-Neutral/Xstream#deployment',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
managed: [
|
||||
{
|
||||
label: '联系咨询',
|
||||
href: 'https://www.svc.plus/contact',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
paygo: [
|
||||
{
|
||||
label: '价格与账单',
|
||||
href: 'https://www.svc.plus/pricing',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
saas: [
|
||||
{
|
||||
label: '注册与订阅',
|
||||
href: 'https://www.svc.plus/xstream/signup',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export default xstream
|
||||
@ -30,7 +30,7 @@
|
||||
"@templates/*": ["src/templates/*"],
|
||||
"@src/*": ["src/*"]
|
||||
},
|
||||
"types": ["node"],
|
||||
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": [
|
||||
|
||||
12295
dashboard/yarn.lock
12295
dashboard/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user