// 文章采集编辑页面 - Preline UI 风格
import { useState, useEffect } from 'react'
import { useNavigate } 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, FormError } from '../components-preline/Input'
import { categoriesApi } from '../api/categories'
import { tagsApi } from '../api/tags'
import { API_BASE_URL } from '../api/config'
// 错误弹窗组件
function ErrorModal({ message, onClose, onClear }) {
if (!message) return null
return (
{message}
)
}
export function UniversalCrawlEdit() {
const navigate = useNavigate()
// 获取当前用户信息
const user = JSON.parse(localStorage.getItem('user') || '{}')
// 采集步骤
const [step, setStep] = useState('input') // input -> crawling -> edit
const [crawlUrl, setCrawlUrl] = useState('')
const [crawling, setCrawling] = useState(false)
const [crawlError, setCrawlError] = useState('')
// 表单数据
const [form, setForm] = useState({
title: '',
content: '',
summary: '',
category_id: '',
tag_ids: [],
is_published: false,
is_draft: true
})
// 采集的图片
const [crawledImages, setCrawledImages] = useState([])
// 分类和标签
const [categories, setCategories] = useState([])
const [tags, setTags] = useState([])
// 保存状态
const [saving, setSaving] = useState(false)
// 错误弹窗
const [showErrorModal, setShowErrorModal] = useState(false)
const [errorModalMessage, setErrorModalMessage] = useState('')
// 加载分类和标签,并清空之前的状态
useEffect(() => {
// 清空表单状态,防止显示之前缓存的数据
setForm({
title: '',
content: '',
summary: '',
category_id: '',
tag_ids: [],
is_published: false,
is_draft: true
})
setCrawledImages([])
setStep('input')
setCrawlError('')
const loadData = async () => {
try {
const [catRes, tagRes] = await Promise.all([
categoriesApi.getCategories(),
tagsApi.getTags()
])
setCategories(catRes.data)
setTags(tagRes.data)
} catch (error) {
console.error('加载数据失败:', error)
}
}
loadData()
}, [])
// 开始采集
const handleCrawl = async (e) => {
e.preventDefault()
if (!crawlUrl.trim()) {
alert('请输入文章链接')
return
}
if (!crawlUrl.startsWith('http://') && !crawlUrl.startsWith('https://')) {
alert('请输入有效的URL(以 http:// 或 https:// 开头)')
return
}
setCrawling(true)
setCrawlError('')
try {
const token = localStorage.getItem('token')
// 添加随机数防止缓存
const nocache = Date.now()
// 构造请求头:有 token 则带 Bearer,同时启用 credentials 以支持 SSO Cookie 认证
const headers = {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`${API_BASE_URL}/universal-crawl/fetch?_=${nocache}`, {
method: 'POST',
headers,
body: JSON.stringify({ url: crawlUrl }),
credentials: 'include'
})
const data = await response.json()
if (!data.success) {
const errorMsg = data.error || '采集失败,请检查链接是否有效'
setCrawlError(errorMsg)
setErrorModalMessage(errorMsg)
setShowErrorModal(true)
setCrawling(false)
return
}
// 填充表单
setForm({
title: data.title || '',
content: data.content || '',
summary: data.summary || '',
category_id: '',
tag_ids: [],
is_published: false,
is_draft: true
})
// 设置图片
setCrawledImages(data.images || [])
// 进入编辑步骤
setStep('edit')
} catch (error) {
const errorMsg = '采集失败: ' + (error.message || '网络错误,请稍后重试')
setCrawlError(errorMsg)
setErrorModalMessage(errorMsg)
setShowErrorModal(true)
} finally {
setCrawling(false)
}
}
// 保存文章
const handleSubmit = async (e, isDraft = false) => {
e.preventDefault()
if (!form.title.trim() || !form.content.trim()) {
alert('请填写标题和内容')
return
}
setSaving(true)
try {
const token = localStorage.getItem('token')
// 将 tag_ids 转换为 tag 名称数组
const tagNames = form.tag_ids.map(tid => {
const tag = tags.find(t => t.id === Number(tid))
return tag ? tag.name : ''
}).filter(Boolean)
const response = await fetch(`${API_BASE_URL}/universal-crawl/save`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {})
},
body: JSON.stringify({
title: form.title,
content: form.content,
content_html: '',
images: crawledImages,
summary: form.summary,
category_id: form.category_id || null,
is_draft: isDraft,
is_published: !isDraft,
tags: tagNames,
source_url: crawlUrl
}),
credentials: 'include'
})
const data = await response.json()
if (!data.success) {
throw new Error(data.detail || data.message || '保存失败')
}
// 提示图片下载情况
if (data.images_failed > 0) {
alert(`文章已保存成功!\n图片:${data.images_saved} 张下载成功,${data.images_failed} 张下载失败(保留原始链接)。`)
}
navigate('/admin/posts')
} catch (error) {
alert('保存失败: ' + (error.message || error.response?.data?.detail || '未知错误'))
} finally {
setSaving(false)
}
}
// 在内容中插入图片
const insertImageToContent = (imagePath) => {
const imageMarkdown = `\n\n\n\n`
setForm({
...form,
content: form.content + imageMarkdown
})
}
// 取消/返回
const handleCancel = () => {
if (confirm('确定要取消吗?未保存的内容将丢失。')) {
navigate('/admin/posts')
}
}
// 退出登录
const handleLogout = async () => {
localStorage.removeItem('token')
await authApi.logout()
localStorage.removeItem('user')
window.location.href = '/login'
}
// 输入URL界面
if (step === 'input') {
return (
setShowErrorModal(false)}
onClear={() => setCrawlUrl('')}
/>
{/* 页面标题 */}
从网页自动采集
输入文章链接,自动提取标题、正文和图片,自动处理编码和数学公式
使用说明
采集功能的使用注意事项
-
支持各类网站、技术博客、学术论文等公开内容的增强采集
-
采集后需要人工编辑确认内容准确性
-
系统会自动提取标题、正文和图片
-
采集的内容默认保存为草稿,需要手动发布
-
请尊重原作者版权,仅用于个人学习参考
)
}
// 编辑界面
return (
{/* 页面标题 */}
{/* 左侧:主要内容 */}
文章内容
编辑文章标题、摘要和正文
setForm({...form, title: e.target.value})}
placeholder="请输入标题"
required
/>
{/* 右侧:设置和图片 */}
{/* 分类和标签 */}
文章设置
设置分类和标签
{/* 采集的图片 */}
{crawledImages.length > 0 && (
采集的图片
共 {crawledImages.length} 张图片,点击插入到文章
{crawledImages.map((img, index) => {
const displayUrl = img.local_path || img.original_url
const insertUrl = img.local_path || img.original_url
return (
)
insertImageToContent(insertUrl)}
/>
insertImageToContent(insertUrl)}
>
点击插入
{img.id && (
✓ 已保存
)}
)
})}
点击图片可插入到文章末尾(带✓的已保存到图库)
)}
{/* 来源信息 */}
来源
文章来源链接
{crawlUrl}
)
}
export default UniversalCrawlEdit