5abc705eae
- 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>
193 lines
6.0 KiB
TypeScript
193 lines
6.0 KiB
TypeScript
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>
|
||
)
|
||
}
|