feat(admin): 官网落地页融进 admin —— / 是官网、/admin 是运维后台
用户要 agent.sundynix.cn/ 打开是营销官网、/admin 是后台,且只用一个前端。 把 sundynix-site/web 的营销落地页融进 sundynix-admin 这一个 Vite 工程(不挂两份 embed): - 拷官网到 src/site/:home(hero/功能/架构/快速上手/下载)+layout(header/footer) +terminal-demo/logo/reveal 等 + content/site.ts(文案本就是本产品)。去掉博客/posts/埋点/RSS (营销 only,零后端改动);use-release 改纯静态版本号。public/ 加 brand logo+favicon。 - Tailwind v4→v3 对齐:site 的 ink/ground/accent/term 等 token 加进 tailwind.config(var 引用) + :root/.dark 变量+keyframes 进 index.css;bg-ground/85 用 color-mix 还原。admin 自身用默认 gray/violet 调色板,不冲突;landing 的 bg-ground 只作用在 SiteLayout 容器、不改 body。 - 路由重构:App.tsx 换 BrowserRouter —— / 官网(公开)、/admin/* 后台(AdminGate 内做 me/Login/AppShell)。 routes.tsx path 改相对、AppShell 链接加 /admin 前缀。@ 别名指向 src/site。 - gateway/Dockerfile/nginx 零改动:单份 dist 挂根 + NoRoute SPA 兜底已支持 BrowserRouter 深链。 验证:tsc + vite build(Tailwind 编译过)+ vitest 41 过;gateway 二进制冒烟打真 serve—— / 返回官网 index.html、/admin 与 /admin/tasks SPA 兜底 200、/brand/*.webp 200、/api/v1 走 API 401; dist 里确认落地页文案已进 bundle。(注:MCP 浏览器当前断开,视觉渲染待部署后线上看。) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { DOWNLOADS } from '@/content/site'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function DownloadGrid() {
|
||||
return (
|
||||
<div className="mx-auto grid max-w-[780px] grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
{DOWNLOADS.map((d) => (
|
||||
<div
|
||||
key={d.os}
|
||||
className="rounded-xl border border-hairline bg-surface p-6 text-center transition-colors hover:border-accent"
|
||||
>
|
||||
<div className="mb-1 text-[15.5px] font-semibold">{d.os}</div>
|
||||
<div className="mb-4 font-mono text-[11.5px] text-ink-3">
|
||||
{d.meta}
|
||||
</div>
|
||||
<a
|
||||
href={d.href}
|
||||
className={cn(
|
||||
'inline-block rounded-[7px] px-5 py-1.5 text-[13.5px] font-medium transition-opacity hover:opacity-90',
|
||||
d.highlight
|
||||
? 'bg-accent text-ground'
|
||||
: 'border border-hairline-strong text-accent-ink',
|
||||
)}
|
||||
>
|
||||
{d.action}
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { SITE } from '@/content/site'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
title: 'PRODUCT',
|
||||
links: [
|
||||
{ label: '功能', href: '/#features' },
|
||||
{ label: '架构', href: '/#architecture' },
|
||||
{ label: '下载', href: '/download', internal: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'RESOURCES',
|
||||
links: [
|
||||
{ label: '下载桌面版', href: '/download', internal: true },
|
||||
{ label: '源码仓库', href: SITE.repo },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-hairline">
|
||||
<div className="mx-auto grid max-w-6xl gap-10 px-6 py-12 md:grid-cols-[1.5fr_1fr_1fr] lg:px-10">
|
||||
<div>
|
||||
<p className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
|
||||
<LogoMark size={30} />
|
||||
sundynix <em className="not-italic text-accent">agentix</em>
|
||||
</p>
|
||||
<p className="mt-2 text-[14px] text-ink-2">{SITE.tagline}</p>
|
||||
<p className="mt-4 font-mono text-[11.5px] tracking-[0.08em] text-ink-3">
|
||||
{SITE.version} · MACOS / WINDOWS / SELF-HOSTED
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{COLUMNS.map((col) => (
|
||||
<nav key={col.title} aria-label={col.title}>
|
||||
<p className="mb-3.5 font-mono text-[11px] tracking-[0.16em] text-ink-3">
|
||||
{col.title}
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
{col.links.map((link) => (
|
||||
<li key={link.label}>
|
||||
{'internal' in link && link.internal ? (
|
||||
<Link
|
||||
to={link.href}
|
||||
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={link.href}
|
||||
target={link.href.startsWith('http') ? '_blank' : undefined}
|
||||
rel={link.href.startsWith('http') ? 'noreferrer' : undefined}
|
||||
className="text-[13.5px] text-ink-2 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-hairline">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-6 gap-y-2 px-6 py-5 font-mono text-[12px] text-ink-3 lg:px-10">
|
||||
<span>© 2026 SUNDYNIX AGENTIX</span>
|
||||
<a
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="transition-colors hover:text-accent-ink"
|
||||
>
|
||||
{SITE.icp}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, NavLink } from 'react-router-dom'
|
||||
import { Menu, Moon, Sun, X } from 'lucide-react'
|
||||
import { useTheme } from '@/components/theme-provider'
|
||||
import { LogoMark } from '@/components/logo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ label: '产品', to: '/', end: true },
|
||||
{ label: '功能', to: '/#features' },
|
||||
{ label: '架构', to: '/#architecture' },
|
||||
]
|
||||
|
||||
function ThemeToggle() {
|
||||
const { resolved, setTheme } = useTheme()
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={resolved === 'dark' ? '切换到浅色主题' : '切换到深色主题'}
|
||||
onClick={() => setTheme(resolved === 'dark' ? 'light' : 'dark')}
|
||||
className="flex size-8 items-center justify-center rounded-full border border-hairline-strong text-ink-2 transition-colors hover:border-accent hover:text-accent-ink"
|
||||
>
|
||||
{resolved === 'dark' ? (
|
||||
<Sun className="size-4" />
|
||||
) : (
|
||||
<Moon className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function SiteHeader() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const links = NAV_ITEMS.map((item) =>
|
||||
item.to.includes('#') ? (
|
||||
<a
|
||||
key={item.to}
|
||||
href={item.to}
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-sm text-ink-2 transition-colors hover:text-ink"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
onClick={() => setOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'text-sm transition-colors hover:text-ink',
|
||||
isActive ? 'text-ink' : 'text-ink-2',
|
||||
)
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="bg-ground-85 sticky top-0 z-40 border-b border-hairline backdrop-blur">
|
||||
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6 lg:px-10">
|
||||
<Link to="/" className="flex items-center gap-2.5 font-mono text-[15px] font-semibold">
|
||||
<LogoMark size={26} />
|
||||
sundynix <em className="not-italic text-accent">agentix</em>
|
||||
</Link>
|
||||
|
||||
<nav className="hidden items-center gap-7 md:flex">
|
||||
{links}
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
to="/download"
|
||||
className="rounded-[7px] bg-accent px-4 py-1.5 text-[13px] font-medium text-ground transition-opacity hover:opacity-90"
|
||||
>
|
||||
下载桌面版
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-3 md:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? '关闭菜单' : '打开菜单'}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex size-8 items-center justify-center text-ink-2"
|
||||
>
|
||||
{open ? <X className="size-5" /> : <Menu className="size-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<nav className="flex flex-col gap-4 border-t border-hairline px-6 py-5 md:hidden">
|
||||
{links}
|
||||
<Link
|
||||
to="/download"
|
||||
onClick={() => setOpen(false)}
|
||||
className="w-fit rounded-[7px] bg-accent px-4 py-1.5 text-[13px] font-medium text-ground"
|
||||
>
|
||||
下载桌面版
|
||||
</Link>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SiteFooter } from '@/components/layout/site-footer'
|
||||
|
||||
export function SiteLayout() {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo(0, 0)
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-ground font-sans text-ink antialiased">
|
||||
<SiteHeader />
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
interface LogoMarkProps {
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** 品牌 Logo:霓虹数据流 S(用户原版位图裁切,源图在 design-assets/) */
|
||||
export function LogoMark({ size = 28, className }: LogoMarkProps) {
|
||||
return (
|
||||
<img
|
||||
src="/brand/logo-mark.webp"
|
||||
width={size}
|
||||
height={size}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
style={{ borderRadius: '22%' }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
/** 进场延迟(毫秒),用于同屏元素错峰 */
|
||||
delay?: number
|
||||
}
|
||||
|
||||
/** 滚动进场:进入视口后淡入上移一次;prefers-reduced-motion 时直接显示 */
|
||||
export function Reveal({ children, className, delay = 0 }: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
const io = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true)
|
||||
io.disconnect()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.15, rootMargin: '0px 0px -40px 0px' },
|
||||
)
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={delay ? { transitionDelay: `${delay}ms` } : undefined}
|
||||
className={cn('reveal', visible && 'reveal-in', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function SectionLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<span className="mb-2.5 block font-mono text-[11px] tracking-[0.18em] text-accent">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { TERMINAL_DEMO } from '@/content/site'
|
||||
|
||||
/** Hero 事件流终端窗 — 双主题共用深色 */
|
||||
export function TerminalDemo() {
|
||||
return (
|
||||
<div className="mx-auto mt-13 max-w-[720px] overflow-hidden rounded-t-xl border border-b-0 border-hairline-strong bg-term text-left">
|
||||
<div className="flex items-center gap-2 border-b border-white/[0.07] px-4 py-2.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span key={i} className="size-2.5 rounded-full bg-white/[0.18]" />
|
||||
))}
|
||||
<span className="ml-2.5 font-mono text-[11.5px] tracking-[0.1em] text-white/40">
|
||||
{TERMINAL_DEMO.title}
|
||||
</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto px-5 pb-6 pt-5 font-mono text-[13.5px] leading-[2.05] text-term-ink">
|
||||
<div className="term-line">
|
||||
<span className="font-semibold text-[#22d3ee]">› </span>
|
||||
{TERMINAL_DEMO.prompt}
|
||||
</div>
|
||||
<div className="term-line text-white/35" style={{ animationDelay: '0.35s' }}>
|
||||
──────────────────────────────────
|
||||
</div>
|
||||
{TERMINAL_DEMO.events.map((ev, i) => (
|
||||
<div
|
||||
key={ev.tag}
|
||||
className="term-line whitespace-nowrap"
|
||||
style={{ animationDelay: `${0.7 + i * 0.45}s` }}
|
||||
>
|
||||
<span className={ev.ok ? 'text-[#a6d96a]' : 'text-[#60a5fa]'}>
|
||||
[{ev.tag}]
|
||||
</span>{' '}
|
||||
{ev.text}
|
||||
{i === TERMINAL_DEMO.events.length - 1 && (
|
||||
<span className="caret-blink ml-1 inline-block h-[15px] w-2 translate-y-0.5 bg-[#22d3ee]" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: Theme
|
||||
resolved: 'light' | 'dark'
|
||||
setTheme: (t: Theme) => void
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null)
|
||||
|
||||
const STORAGE_KEY = 'sundynix-theme'
|
||||
|
||||
function systemTheme(): 'light' | 'dark' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(
|
||||
() => (localStorage.getItem(STORAGE_KEY) as Theme) ?? 'system',
|
||||
)
|
||||
const [resolved, setResolved] = useState<'light' | 'dark'>(() =>
|
||||
theme === 'system' ? systemTheme() : theme,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const apply = () => {
|
||||
const r = theme === 'system' ? systemTheme() : theme
|
||||
setResolved(r)
|
||||
document.documentElement.classList.toggle('dark', r === 'dark')
|
||||
}
|
||||
apply()
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mq.addEventListener('change', apply)
|
||||
return () => mq.removeEventListener('change', apply)
|
||||
}, [theme])
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
localStorage.setItem(STORAGE_KEY, t)
|
||||
setThemeState(t)
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext)
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider')
|
||||
return ctx
|
||||
}
|
||||
Reference in New Issue
Block a user