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
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState, type FormEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ArrowLeft } from 'lucide-react'
import {
createPost,
fetchPost,
updatePost,
type PostForm,
} from '@/api/posts'
const EMPTY: PostForm = {
slug: '',
title: '',
category: '',
summary: '',
content: '',
published: false,
}
export default function PostEditPage() {
const { id } = useParams()
const navigate = useNavigate()
const isEdit = Boolean(id)
const [form, setForm] = useState<PostForm>(EMPTY)
const [loading, setLoading] = useState(isEdit)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (!id) return
let cancelled = false
fetchPost(id)
.then((post) => {
if (cancelled) return
setForm({
slug: post.slug,
title: post.title,
category: post.category,
summary: post.summary,
content: post.content ?? '',
published: post.published_at !== null,
})
setLoading(false)
})
.catch((err: Error) => {
if (cancelled) return
setError(err.message)
setLoading(false)
})
return () => {
cancelled = true
}
}, [id])
const set = <K extends keyof PostForm>(key: K, value: PostForm[K]) =>
setForm((f) => ({ ...f, [key]: value }))
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
setSaving(true)
setError('')
try {
if (isEdit && id) {
await updatePost(id, form)
} else {
await createPost(form)
}
navigate('/')
} catch (err) {
setError(err instanceof Error ? err.message : '保存失败')
} finally {
setSaving(false)
}
}
const inputCls =
'w-full rounded-lg border border-hairline-strong bg-ground px-3.5 py-2 text-[14px] outline-none transition-colors focus:border-accent'
return (
<div>
<Link
to="/"
className="mb-6 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" />
</Link>
<h1 className="mb-6 text-[22px] font-[650] tracking-[-0.01em]">
{isEdit ? '编辑文章' : '新建文章'}
</h1>
{loading ? (
<div className="space-y-4">
<div className="h-10 animate-pulse rounded-lg bg-code" />
<div className="h-64 animate-pulse rounded-lg bg-code" />
</div>
) : (
<form onSubmit={onSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-5 md:grid-cols-2">
<label className="block">
<span className="mb-1.5 block text-[13px] text-ink-2"> *</span>
<input
type="text"
value={form.title}
onChange={(e) => set('title', e.target.value)}
required
className={inputCls}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[13px] text-ink-2">
Slug *URL my-first-post
</span>
<input
type="text"
value={form.slug}
onChange={(e) => set('slug', e.target.value)}
required
pattern="[a-z0-9]+(-[a-z0-9]+)*"
title="小写字母、数字,用 - 连接"
className={`${inputCls} font-mono`}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[13px] text-ink-2">
RELEASE / ENGINEERING
</span>
<input
type="text"
value={form.category}
onChange={(e) => set('category', e.target.value.toUpperCase())}
className={`${inputCls} font-mono`}
/>
</label>
<label className="block">
<span className="mb-1.5 block text-[13px] text-ink-2"></span>
<input
type="text"
value={form.summary}
onChange={(e) => set('summary', e.target.value)}
className={inputCls}
/>
</label>
</div>
<label className="block">
<span className="mb-1.5 block text-[13px] text-ink-2">
Markdown
</span>
<textarea
value={form.content}
onChange={(e) => set('content', e.target.value)}
rows={18}
spellCheck={false}
className={`${inputCls} resize-y font-mono text-[13.5px] leading-relaxed`}
/>
</label>
<div className="flex items-center justify-between border-t border-hairline pt-5">
<label className="flex cursor-pointer items-center gap-2.5 text-[14px]">
<input
type="checkbox"
checked={form.published}
onChange={(e) => set('published', e.target.checked)}
className="size-4 accent-(--accent)"
/>
<span className="text-[12.5px] text-ink-3">
稿
</span>
</label>
<div className="flex items-center gap-3">
{error && (
<span className="text-[13px] text-red-600 dark:text-red-400">
{error}
</span>
)}
<button
type="submit"
disabled={saving}
className="rounded-lg bg-accent px-6 py-2 text-[14px] font-medium text-ground transition-opacity hover:opacity-90 disabled:opacity-60"
>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
</form>
)}
</div>
)
}