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:
Blizzard
2026-07-17 11:03:31 +08:00
parent d46267dde1
commit 5abc705eae
53 changed files with 5774 additions and 38 deletions
+1542 -2
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -10,13 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.18.1",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.3"
},
+2
View File
@@ -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>
+67
View File
@@ -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)
}
+30
View File
@@ -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% {
+94
View File
@@ -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
View File
@@ -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>
)
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig({
port: 5173,
proxy: {
// 联调 Go 后端时启用:/api → gin
'/api': 'http://localhost:8080',
'/api': 'http://localhost:8090',
},
},
})