// 文章编辑页面 - 支持 Markdown / HTML 双格式
import { useState, useEffect, useRef, useCallback } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { AdminLayout } from '../components-preline/Sidebar'
import { authApi } from '../api/auth'
import { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter } from '../components-preline/Card'
import { Button } from '../components-preline/Button'
import { Input, Textarea, Label } from '../components-preline/Input'
import { postsApi } from '../api/posts'
import { categoriesApi } from '../api/categories'
import { tagsApi } from '../api/tags'
import { API_BASE_URL } from '../api/config'
import MDEditor from '@uiw/react-md-editor'
import '@uiw/react-md-editor/markdown-editor.css'
import ReactQuill from 'react-quill-new'
import 'react-quill-new/dist/quill.snow.css'
// 图片上传组件
function ImageUploader({ uploadedImages, setUploadedImages, onInsertImage }) {
const fileInputRef = useRef(null)
const [uploading, setUploading] = useState(false)
const handleImageUpload = async (e) => {
const files = e.target.files
if (!files || files.length === 0) return
setUploading(true)
const formData = new FormData()
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i])
}
try {
const token = localStorage.getItem('token')
const res = await fetch(`${API_BASE_URL}/images/upload-multiple`, {
method: 'POST',
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
credentials: 'include',
body: formData
})
const data = await res.json()
if (data.uploaded && data.uploaded.length > 0) {
setUploadedImages(prev => [...prev, ...data.uploaded])
}
} catch (error) {
alert('上传失败')
} finally {
setUploading(false)
}
}
return (
{uploadedImages.length > 0 && (
本次上传的图片
{uploadedImages.map((img) => (
onInsertImage(img)}
title="点击插入到文章"
>
))}
点击图片插入到文章末尾
)}
使用说明
- 1. 点击上传图片
- 2. 点击缩略图插入文章
- 3. Markdown 模式插入

- 4. HTML 模式插入图片标签
)
}
// 标签选择器
function TagSelector({ tags, selectedIds, onToggle }) {
return (
{tags.map(tag => (
))}
)
}
export function PostEdit() {
const { id } = useParams()
const navigate = useNavigate()
const isEdit = Boolean(id)
const quillRef = useRef(null)
const [form, setForm] = useState({
title: '',
content: '',
content_type: 'markdown',
summary: '',
category_id: '',
tag_ids: [],
is_published: true,
is_draft: false
})
const [categories, setCategories] = useState([])
const [tags, setTags] = useState([])
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [uploadedImages, setUploadedImages] = useState([])
const user = JSON.parse(localStorage.getItem('user') || '{}')
useEffect(() => {
fetchCategories()
fetchTags()
if (isEdit) {
fetchPost()
} else {
// 新建文章时重置表单,防止从编辑页切换过来残留旧数据
setForm({
title: '',
content: '',
content_type: 'markdown',
summary: '',
category_id: '',
tag_ids: [],
is_published: true,
is_draft: false
})
}
}, [id])
const fetchCategories = async () => {
try {
const res = await categoriesApi.getCategories()
setCategories(res.data)
} catch (error) {
console.error('获取分类失败', error)
}
}
const fetchTags = async () => {
try {
const res = await tagsApi.getTags()
setTags(res.data)
} catch (error) {
console.error('获取标签失败', error)
}
}
const fetchPost = async () => {
setLoading(true)
try {
const res = await postsApi.getPost(id)
const post = res.data
setForm({
title: post.title,
content: post.content,
content_type: post.content_type || 'markdown',
summary: post.summary || '',
category_id: post.category_id || '',
tag_ids: post.tags.map(t => t.id),
is_published: post.is_published,
is_draft: post.is_draft
})
} catch (error) {
alert('获取文章失败')
navigate('/admin/posts')
} finally {
setLoading(false)
}
}
const handleSubmit = async (e, isDraft = false) => {
e.preventDefault()
if (!form.title.trim() || !form.content.trim()) {
alert('请填写标题和内容')
return
}
setSaving(true)
try {
const data = {
...form,
category_id: form.category_id || null,
is_draft: isDraft
}
if (isEdit) {
await postsApi.updatePost(id, data)
} else {
await postsApi.createPost(data)
}
navigate('/admin/posts')
} catch (error) {
alert('保存失败: ' + (error.response?.data?.detail || '未知错误'))
} finally {
setSaving(false)
}
}
const handleChange = (e) => {
const { name, value, type, checked } = e.target
setForm(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}))
}
const handleTagToggle = (tagId) => {
setForm(prev => ({
...prev,
tag_ids: prev.tag_ids.includes(tagId)
? prev.tag_ids.filter(id => id !== tagId)
: [...prev.tag_ids, tagId]
}))
}
// 插入图片:根据当前模式决定格式
const insertImageToEditor = useCallback((image) => {
if (form.content_type === 'html') {
// HTML 模式:通过 Quill API 插入
if (quillRef.current) {
const quill = quillRef.current.getEditor()
const range = quill.getSelection(true)
const index = range ? range.index : quill.getLength()
quill.insertEmbed(index, 'image', image.file_path, 'user')
quill.setSelection(index + 1, 0, 'silent')
}
} else {
// Markdown 模式:追加 markdown 图片语法
const markdown = `\n\n`
setForm(prev => ({
...prev,
content: prev.content + markdown
}))
}
}, [form.content_type])
const handleLogout = async () => {
localStorage.removeItem('token')
await authApi.logout()
localStorage.removeItem('user')
window.location.href = '/login'
}
const quillModules = {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ indent: '-1' }, { indent: '+1' }],
['link', 'image'],
['blockquote', 'code-block'],
[{ color: [] }, { background: [] }],
['clean']
]
}
if (loading) {
return (
)
}
return (
{isEdit ? '编辑文章' : '写文章'}
{isEdit ? '修改文章内容和设置' : '创建新的博客文章'}
)
}
export default PostEdit