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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user