feat: 官网 + 管理端 + Gin/GORM 后端首个完整版本
- web/: 产品官网(Hero 事件流终端、功能矩阵、架构、快速开始、下载、博客 + Markdown 详情页),青瓷绿双主题 - admin/: 内容管理(JWT 登录、文章分页/搜索/CRUD、草稿与发布),embed 挂 /admin - server/: Gin + GORM,MySQL(DSN 走 .env,SQLite 兜底);规范落地:sundynix_ 表前缀、字符串雪花主键、snake_case 列名、统一响应信封、公共分页参数 - 双 SPA embed 单二进制部署,make build 一键出包 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { Route, Routes } from 'react-router-dom'
|
||||
import { SiteLayout } from '@/components/layout/site-layout'
|
||||
import HomePage from '@/pages/home'
|
||||
import BlogPage from '@/pages/blog'
|
||||
import BlogPostPage from '@/pages/blog-post'
|
||||
import DownloadPage from '@/pages/download'
|
||||
import NotFoundPage from '@/pages/not-found'
|
||||
|
||||
@@ -11,6 +12,7 @@ export default function App() {
|
||||
<Route element={<SiteLayout />}>
|
||||
<Route index element={<HomePage />} />
|
||||
<Route path="blog" element={<BlogPage />} />
|
||||
<Route path="blog/:slug" element={<BlogPostPage />} />
|
||||
<Route path="download" element={<DownloadPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/** 博客 API 封装 — dev 下由 vite 代理到 :8090,生产同源。
|
||||
* 后端统一响应信封:{ code, message, data },code=0 为成功。 */
|
||||
|
||||
export interface PostListItem {
|
||||
id: string
|
||||
slug: string
|
||||
title: string
|
||||
category: string
|
||||
summary: string
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
export interface Post extends PostListItem {
|
||||
content: string
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number
|
||||
code: number
|
||||
|
||||
constructor(status: number, code: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string): Promise<T> {
|
||||
const res = await fetch(path)
|
||||
let body: Envelope<T> | null = null
|
||||
try {
|
||||
body = (await res.json()) as Envelope<T>
|
||||
} catch {
|
||||
// 非 JSON 响应
|
||||
}
|
||||
if (!res.ok || !body || body.code !== 0) {
|
||||
throw new ApiError(
|
||||
res.status,
|
||||
body?.code ?? -1,
|
||||
body?.message ?? `请求失败(${res.status})`,
|
||||
)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
export function fetchPosts(): Promise<PostListItem[]> {
|
||||
return request<PostListItem[]>('/api/posts')
|
||||
}
|
||||
|
||||
export function fetchPost(slug: string): Promise<Post> {
|
||||
return request<Post>(`/api/posts/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
export function isNotFound(err: unknown): boolean {
|
||||
return err instanceof ApiError && err.status === 404
|
||||
}
|
||||
|
||||
export function formatDate(iso: string | null): string {
|
||||
if (!iso) return ''
|
||||
return iso.slice(0, 10)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -74,6 +75,35 @@ body {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* 文章排版 — prose 对齐设计 token */
|
||||
.prose-site {
|
||||
--tw-prose-body: var(--ink-2);
|
||||
--tw-prose-headings: var(--ink);
|
||||
--tw-prose-links: var(--accent-ink);
|
||||
--tw-prose-bold: var(--ink);
|
||||
--tw-prose-counters: var(--ink-3);
|
||||
--tw-prose-bullets: var(--hairline-strong);
|
||||
--tw-prose-hr: var(--hairline);
|
||||
--tw-prose-quotes: var(--ink-2);
|
||||
--tw-prose-quote-borders: var(--accent);
|
||||
--tw-prose-code: var(--accent-ink);
|
||||
--tw-prose-pre-bg: var(--term-bg);
|
||||
--tw-prose-pre-code: var(--term-ink);
|
||||
--tw-prose-th-borders: var(--hairline-strong);
|
||||
--tw-prose-td-borders: var(--hairline);
|
||||
--tw-prose-captions: var(--ink-3);
|
||||
}
|
||||
.prose-site :where(code):not(:where(pre code))::before,
|
||||
.prose-site :where(code):not(:where(pre code))::after {
|
||||
content: none;
|
||||
}
|
||||
.prose-site :where(code):not(:where(pre code)) {
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 终端光标 */
|
||||
@keyframes caret-blink {
|
||||
50% {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { fetchPost, formatDate, isNotFound, type Post } from '@/api/posts'
|
||||
import NotFoundPage from '@/pages/not-found'
|
||||
|
||||
type State =
|
||||
| { status: 'loading' }
|
||||
| { status: 'not-found' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; post: Post }
|
||||
|
||||
export default function BlogPostPage() {
|
||||
const { slug = '' } = useParams()
|
||||
const [state, setState] = useState<State>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setState({ status: 'loading' })
|
||||
fetchPost(slug)
|
||||
.then((post) => {
|
||||
if (!cancelled) setState({ status: 'ready', post })
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return
|
||||
setState(
|
||||
isNotFound(err)
|
||||
? { status: 'not-found' }
|
||||
: { status: 'error', message: err.message },
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [slug])
|
||||
|
||||
if (state.status === 'not-found') return <NotFoundPage />
|
||||
|
||||
return (
|
||||
<section className="px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-[720px]">
|
||||
<Link
|
||||
to="/blog"
|
||||
className="mb-8 inline-flex items-center gap-1.5 font-mono text-[12.5px] tracking-[0.08em] text-ink-3 transition-colors hover:text-accent-ink"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" /> BLOG
|
||||
</Link>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<div className="space-y-4">
|
||||
<div className="h-9 w-3/4 animate-pulse rounded-lg bg-code" />
|
||||
<div className="h-4 w-1/3 animate-pulse rounded bg-code" />
|
||||
<div className="mt-8 h-48 animate-pulse rounded-lg bg-code" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<div className="rounded-lg border border-hairline bg-surface px-5 py-4 text-[14.5px] text-ink-2">
|
||||
加载失败:{state.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'ready' && (
|
||||
<article>
|
||||
<header className="mb-9 border-b border-hairline pb-8">
|
||||
<div className="mb-4 flex items-center gap-4 font-mono text-[12px] tracking-[0.08em] text-ink-3">
|
||||
<time className="tabular-nums">
|
||||
{formatDate(state.post.published_at)}
|
||||
</time>
|
||||
<span className="text-accent-ink">{state.post.category}</span>
|
||||
</div>
|
||||
<h1 className="text-balance text-[30px] font-[650] leading-[1.3] tracking-[-0.02em]">
|
||||
{state.post.title}
|
||||
</h1>
|
||||
{state.post.summary && (
|
||||
<p className="mt-4 text-[15.5px] text-ink-2">
|
||||
{state.post.summary}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="prose prose-site max-w-none prose-headings:tracking-[-0.01em] prose-pre:rounded-[10px]">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>
|
||||
{state.post.content}
|
||||
</Markdown>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
+63
-35
@@ -1,25 +1,30 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { fetchPosts, formatDate, type PostListItem } from '@/api/posts'
|
||||
import { SectionLabel } from '@/components/section-label'
|
||||
|
||||
/** 占位数据 — 接入后端后替换为 /api/posts */
|
||||
const POSTS = [
|
||||
{
|
||||
date: '2026-07-02',
|
||||
title: 'sundynix agentix v0.1.2 发布:应用内更新与团队视图',
|
||||
category: 'RELEASE',
|
||||
},
|
||||
{
|
||||
date: '2026-06-18',
|
||||
title: '三路混合检索是怎么工作的:vector + fulltext + graph',
|
||||
category: 'ENGINEERING',
|
||||
},
|
||||
{
|
||||
date: '2026-05-30',
|
||||
title: '为什么我们选择事件驱动:NATS 零拷贝骨干网设计记',
|
||||
category: 'ARCHITECTURE',
|
||||
},
|
||||
]
|
||||
type State =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; message: string }
|
||||
| { status: 'ready'; posts: PostListItem[] }
|
||||
|
||||
export default function BlogPage() {
|
||||
const [state, setState] = useState<State>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetchPosts()
|
||||
.then((posts) => {
|
||||
if (!cancelled) setState({ status: 'ready', posts })
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled) setState({ status: 'error', message: err.message })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="px-6 py-16 lg:px-10">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
@@ -29,24 +34,47 @@ export default function BlogPage() {
|
||||
博客与更新日志
|
||||
</h1>
|
||||
</div>
|
||||
<div>
|
||||
{POSTS.map((post) => (
|
||||
<article
|
||||
key={post.title}
|
||||
className="grid grid-cols-1 items-baseline gap-1 border-b border-hairline py-4.5 sm:grid-cols-[110px_1fr_auto] sm:gap-6"
|
||||
>
|
||||
<time className="font-mono text-[12.5px] tabular-nums text-ink-3">
|
||||
{post.date}
|
||||
</time>
|
||||
<h2 className="text-[15.5px] font-medium transition-colors hover:text-accent-ink">
|
||||
<a href="#">{post.title}</a>
|
||||
</h2>
|
||||
<span className="font-mono text-[11.5px] tracking-[0.08em] text-ink-3">
|
||||
{post.category}
|
||||
</span>
|
||||
</article>
|
||||
|
||||
{state.status === 'loading' && (
|
||||
<div className="space-y-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-12 animate-pulse rounded-lg bg-code"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'error' && (
|
||||
<div className="rounded-lg border border-hairline bg-surface px-5 py-4 text-[14.5px] text-ink-2">
|
||||
加载失败:{state.message}。确认后端已启动(make dev-server)。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'ready' &&
|
||||
(state.posts.length === 0 ? (
|
||||
<p className="text-[14.5px] text-ink-2">还没有文章。</p>
|
||||
) : (
|
||||
<div>
|
||||
{state.posts.map((post) => (
|
||||
<article
|
||||
key={post.slug}
|
||||
className="grid grid-cols-1 items-baseline gap-1 border-b border-hairline py-4.5 sm:grid-cols-[110px_1fr_auto] sm:gap-6"
|
||||
>
|
||||
<time className="font-mono text-[12.5px] tabular-nums text-ink-3">
|
||||
{formatDate(post.published_at)}
|
||||
</time>
|
||||
<h2 className="text-[15.5px] font-medium transition-colors hover:text-accent-ink">
|
||||
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
|
||||
</h2>
|
||||
<span className="font-mono text-[11.5px] tracking-[0.08em] text-ink-3">
|
||||
{post.category}
|
||||
</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user