572 lines
23 KiB
TypeScript
572 lines
23 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
RefreshCw,
|
|
Lock,
|
|
Newspaper,
|
|
Plus,
|
|
Trash2,
|
|
LogOut,
|
|
Rss,
|
|
Settings,
|
|
Check,
|
|
X,
|
|
Eye,
|
|
Globe
|
|
} from 'lucide-react'
|
|
|
|
type News = {
|
|
id: number
|
|
title: string
|
|
category: string
|
|
excerpt: string
|
|
createdAt: string
|
|
}
|
|
|
|
type AutoFetchedNews = {
|
|
id: number
|
|
title: string
|
|
source: string
|
|
url: string
|
|
imageUrl: string
|
|
excerpt: string
|
|
category: string
|
|
publishTime: string
|
|
status: string
|
|
isDigitalEconomy: boolean
|
|
createdAt: string
|
|
}
|
|
|
|
export default function AdminPage() {
|
|
const [activeTab, setActiveTab] = useState<'news' | 'autofetch'>('news')
|
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
|
const [password, setPassword] = useState('')
|
|
const [newsList, setNewsList] = useState<News[]>([])
|
|
const [autoFetchedNews, setAutoFetchedNews] = useState<AutoFetchedNews[]>([])
|
|
const [isLoading, setIsLoading] = useState(false)
|
|
|
|
// 新闻发布表单状态
|
|
const [showAddNews, setShowAddNews] = useState(false)
|
|
const [newNews, setNewNews] = useState({
|
|
title: '',
|
|
category: '公司新闻',
|
|
excerpt: '',
|
|
content: ''
|
|
})
|
|
|
|
// 自动抓取状态
|
|
const [isAutoFetching, setIsAutoFetching] = useState(false)
|
|
const [fetchStatus, setFetchStatus] = useState<{
|
|
lastFetch: string | null
|
|
nextFetch: string | null
|
|
}>({
|
|
lastFetch: null,
|
|
nextFetch: null
|
|
})
|
|
|
|
const handleLogin = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
if (password === 'admin123') {
|
|
setIsAuthenticated(true)
|
|
fetchAllData()
|
|
} else {
|
|
alert('密码错误')
|
|
}
|
|
}
|
|
|
|
const fetchAllData = async () => {
|
|
setIsLoading(true)
|
|
try {
|
|
const [newsRes, autoFetchRes] = await Promise.all([
|
|
fetch('/api/news'),
|
|
fetch('/api/auto-fetch')
|
|
])
|
|
|
|
if (newsRes.ok) {
|
|
const data = await newsRes.json()
|
|
setNewsList(data.news || [])
|
|
}
|
|
if (autoFetchRes.ok) {
|
|
const data = await autoFetchRes.json()
|
|
// 合并待审核和已批准的内容
|
|
const allNews = [
|
|
...(data.pending || []),
|
|
...(data.approved || []).filter((a: AutoFetchedNews) =>
|
|
!data.pending?.some((p: AutoFetchedNews) => p.id === a.id)
|
|
)
|
|
]
|
|
setAutoFetchedNews(allNews)
|
|
setFetchStatus({
|
|
lastFetch: data.lastFetch,
|
|
nextFetch: data.nextFetch
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('获取数据失败', error)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleAddNews = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
try {
|
|
const res = await fetch('/api/news', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newNews)
|
|
})
|
|
if (res.ok) {
|
|
setNewNews({ title: '', category: '公司新闻', excerpt: '', content: '' })
|
|
setShowAddNews(false)
|
|
fetchAllData()
|
|
}
|
|
} catch (error) {
|
|
alert('发布失败')
|
|
}
|
|
}
|
|
|
|
// 自动抓取相关函数
|
|
const triggerAutoFetch = async () => {
|
|
setIsAutoFetching(true)
|
|
try {
|
|
const res = await fetch('/api/auto-fetch', { method: 'POST' })
|
|
const data = await res.json()
|
|
|
|
if (data.success) {
|
|
alert(`成功抓取 ${data.articles?.length || 0} 条资讯`)
|
|
fetchAllData()
|
|
} else {
|
|
alert('抓取失败:' + data.error)
|
|
}
|
|
} catch (error) {
|
|
alert('抓取失败,请稍后重试')
|
|
} finally {
|
|
setIsAutoFetching(false)
|
|
}
|
|
}
|
|
|
|
const approveArticle = async (id: number) => {
|
|
try {
|
|
const res = await fetch(`/api/auto-fetch/${id}/approve`, { method: 'POST' })
|
|
if (res.ok) {
|
|
fetchAllData()
|
|
}
|
|
} catch (error) {
|
|
alert('操作失败')
|
|
}
|
|
}
|
|
|
|
const rejectArticle = async (id: number) => {
|
|
const reason = prompt('请输入拒绝原因:')
|
|
if (reason === null) return
|
|
|
|
try {
|
|
const res = await fetch(`/api/auto-fetch/${id}/reject`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ reason })
|
|
})
|
|
if (res.ok) {
|
|
fetchAllData()
|
|
}
|
|
} catch (error) {
|
|
alert('操作失败')
|
|
}
|
|
}
|
|
|
|
const deleteArticle = async (id: number) => {
|
|
if (!confirm('确定要删除这条资讯吗?')) return
|
|
|
|
try {
|
|
const res = await fetch(`/api/auto-fetch/${id}`, { method: 'DELETE' })
|
|
if (res.ok) {
|
|
fetchAllData()
|
|
}
|
|
} catch (error) {
|
|
alert('删除失败')
|
|
}
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center bg-[#0A1931]">
|
|
<Card className="w-[350px] shadow-2xl border-0">
|
|
<CardHeader className="text-center pb-8 pt-10">
|
|
<CardTitle className="text-2xl font-black text-[#0A1931] flex flex-col items-center gap-4">
|
|
<div className="w-16 h-16 bg-[#FF6600] rounded-2xl flex items-center justify-center text-white shadow-lg">
|
|
<Lock className="w-8 h-8" />
|
|
</div>
|
|
白马家园后台管理
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pb-10 px-8">
|
|
<form onSubmit={handleLogin} className="space-y-6">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-bold text-gray-500">管理员密码</label>
|
|
<Input
|
|
type="password"
|
|
placeholder="请输入密码"
|
|
className="h-12 border-gray-200 focus:border-[#FF6600] focus:ring-[#FF6600]"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<Button type="submit" className="w-full h-12 bg-[#0A1931] hover:bg-[#185ADB] font-bold text-lg rounded-xl transition-all">
|
|
进入管理中心
|
|
</Button>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex">
|
|
{/* Sidebar */}
|
|
<aside className="w-64 bg-[#0A1931] text-white hidden md:flex flex-col">
|
|
<div className="p-8 border-b border-white/10">
|
|
<span className="text-xl font-black">管理中心</span>
|
|
</div>
|
|
<nav className="flex-grow p-4 space-y-2">
|
|
<button
|
|
onClick={() => setActiveTab('news')}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all font-bold ${activeTab === 'news' ? 'bg-[#FF6600] text-white shadow-lg' : 'hover:bg-white/5 text-gray-400'}`}
|
|
>
|
|
<Newspaper className="w-5 h-5" />
|
|
新闻动态
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('autofetch')}
|
|
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all font-bold ${activeTab === 'autofetch' ? 'bg-[#FF6600] text-white shadow-lg' : 'hover:bg-white/5 text-gray-400'}`}
|
|
>
|
|
<Rss className="w-5 h-5" />
|
|
自动抓取
|
|
</button>
|
|
</nav>
|
|
<div className="p-6">
|
|
<Button variant="ghost" className="w-full text-gray-400 hover:text-white" onClick={() => setIsAuthenticated(false)}>
|
|
<LogOut className="w-4 h-4 mr-2" /> 退出登录
|
|
</Button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Content */}
|
|
<main className="flex-grow pt-24 pb-12 px-4 md:px-8 overflow-y-auto">
|
|
<div className="max-w-6xl mx-auto">
|
|
|
|
<div className="flex justify-between items-center mb-10">
|
|
<div>
|
|
<h1 className="text-3xl font-black text-[#0A1931]">
|
|
{activeTab === 'news' && '公司/行业动态'}
|
|
{activeTab === 'autofetch' && '资讯自动抓取'}
|
|
</h1>
|
|
<p className="text-gray-500 mt-1">
|
|
{activeTab === 'news' && '发布和管理最新的公司动态与行业资讯'}
|
|
{activeTab === 'autofetch' && '自动抓取白马精选公众号等资讯,智能筛选后展示'}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3">
|
|
{activeTab === 'news' && (
|
|
<Button
|
|
onClick={() => setShowAddNews(!showAddNews)}
|
|
className="bg-[#FF6600] hover:bg-[#FF8C00] font-bold"
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" /> 发布动态
|
|
</Button>
|
|
)}
|
|
{activeTab === 'autofetch' && (
|
|
<Button
|
|
onClick={triggerAutoFetch}
|
|
disabled={isAutoFetching}
|
|
className="bg-[#FF6600] hover:bg-[#FF8C00] font-bold"
|
|
>
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${isAutoFetching ? 'animate-spin' : ''}`} />
|
|
{isAutoFetching ? '抓取中...' : '立即抓取'}
|
|
</Button>
|
|
)}
|
|
<Button variant="outline" onClick={fetchAllData} disabled={isLoading} className="border-gray-200">
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${isLoading ? 'animate-spin' : ''}`} /> 刷新
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 新闻发布表单 */}
|
|
{activeTab === 'news' && showAddNews && (
|
|
<Card className="mb-10 border-2 border-[#FF6600]/20 shadow-xl overflow-hidden">
|
|
<CardHeader className="bg-[#FF6600]/5 border-b">
|
|
<CardTitle className="text-lg">撰写新动态</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="pt-6">
|
|
<form onSubmit={handleAddNews} className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-bold">标题</label>
|
|
<Input
|
|
placeholder="输入新闻标题"
|
|
required
|
|
onChange={(e) => setNewNews({...newNews, title: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-bold">分类</label>
|
|
<select
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
value={newNews.category}
|
|
onChange={(e) => setNewNews({...newNews, category: e.target.value})}
|
|
>
|
|
<option>公司新闻</option>
|
|
<option>行业洞察</option>
|
|
<option>媒体报道</option>
|
|
<option>产品发布</option>
|
|
<option>荣誉获奖</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-bold">摘要</label>
|
|
<Input
|
|
placeholder="输入新闻简介"
|
|
value={newNews.excerpt}
|
|
onChange={(e) => setNewNews({...newNews, excerpt: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-bold">详细内容</label>
|
|
<Textarea
|
|
placeholder="输入详细报道内容..."
|
|
className="min-h-[150px]"
|
|
value={newNews.content}
|
|
onChange={(e) => setNewNews({...newNews, content: e.target.value})}
|
|
/>
|
|
</div>
|
|
<div className="flex gap-3 justify-end">
|
|
<Button variant="ghost" onClick={() => setShowAddNews(false)}>取消</Button>
|
|
<Button type="submit" className="bg-[#0A1931] hover:bg-[#185ADB] px-8">立即发布</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* 自动抓取状态面板 */}
|
|
{activeTab === 'autofetch' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
|
<Card className="bg-gradient-to-br from-[#FF6600] to-[#FF8C00] text-white">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
|
<Rss className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm opacity-80">上次抓取时间</p>
|
|
<p className="text-lg font-bold">
|
|
{fetchStatus.lastFetch
|
|
? new Date(fetchStatus.lastFetch).toLocaleString('zh-CN')
|
|
: '尚未抓取'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gradient-to-br from-[#0A1931] to-[#185ADB] text-white">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
|
<Settings className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm opacity-80">下次抓取时间</p>
|
|
<p className="text-lg font-bold">
|
|
{fetchStatus.nextFetch
|
|
? new Date(fetchStatus.nextFetch).toLocaleString('zh-CN')
|
|
: '每6小时自动抓取'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-gradient-to-br from-green-500 to-emerald-600 text-white">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center gap-4">
|
|
<div className="w-12 h-12 bg-white/20 rounded-xl flex items-center justify-center">
|
|
<Check className="w-6 h-6" />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm opacity-80">待审核资讯</p>
|
|
<p className="text-2xl font-bold">
|
|
{autoFetchedNews.filter(n => n.status === 'PENDING').length} 条
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
|
|
{/* 数据列表 */}
|
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
|
{activeTab === 'news' && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="text-xs text-gray-400 uppercase bg-gray-50/50 border-b">
|
|
<tr>
|
|
<th className="px-6 py-4">发布时间</th>
|
|
<th className="px-6 py-4">标题</th>
|
|
<th className="px-6 py-4">分类</th>
|
|
<th className="px-6 py-4">摘要</th>
|
|
<th className="px-6 py-4 text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{newsList.length === 0 ? (
|
|
<tr><td colSpan={5} className="text-center py-20 text-gray-400">暂无动态数据</td></tr>
|
|
) : (
|
|
newsList.map((n) => (
|
|
<tr key={n.id} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-6 py-4 text-xs text-gray-400 whitespace-nowrap">{new Date(n.createdAt).toLocaleDateString()}</td>
|
|
<td className="px-6 py-4 font-bold text-[#0A1931]">{n.title}</td>
|
|
<td className="px-6 py-4">
|
|
<span className="bg-orange-50 text-[#FF6600] px-2.5 py-1 rounded-full text-xs font-bold">{n.category}</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-gray-500 max-w-xs truncate">{n.excerpt}</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<Button variant="ghost" size="icon" className="text-gray-300 hover:text-red-500">
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'autofetch' && (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm text-left">
|
|
<thead className="text-xs text-gray-400 uppercase bg-gray-50/50 border-b">
|
|
<tr>
|
|
<th className="px-6 py-4">发布时间</th>
|
|
<th className="px-6 py-4">标题</th>
|
|
<th className="px-6 py-4">来源</th>
|
|
<th className="px-6 py-4">分类</th>
|
|
<th className="px-6 py-4">状态</th>
|
|
<th className="px-6 py-4 text-right">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{autoFetchedNews.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="text-center py-20">
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Rss className="w-16 h-16 text-gray-300" />
|
|
<div>
|
|
<p className="text-gray-500 font-medium">暂无抓取数据</p>
|
|
<p className="text-gray-400 text-sm">点击"立即抓取"按钮开始抓取资讯</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
autoFetchedNews.map((news) => (
|
|
<tr key={news.id} className="hover:bg-gray-50 transition-colors">
|
|
<td className="px-6 py-4 text-xs text-gray-400 whitespace-nowrap">
|
|
{new Date(news.publishTime).toLocaleDateString()}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="font-bold text-[#0A1931] max-w-xs truncate">{news.title}</div>
|
|
{news.excerpt && (
|
|
<div className="text-gray-400 text-xs mt-1 max-w-xs truncate">{news.excerpt}</div>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-2">
|
|
<Globe className="w-4 h-4 text-[#FF6600]" />
|
|
<span className="text-gray-600">{news.source}</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className="bg-blue-50 text-blue-600 px-2.5 py-1 rounded-full text-xs font-bold">
|
|
{news.category}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4">
|
|
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${
|
|
news.status === 'APPROVED' ? 'bg-green-50 text-green-600' :
|
|
news.status === 'REJECTED' ? 'bg-red-50 text-red-600' :
|
|
'bg-yellow-50 text-yellow-600'
|
|
}`}>
|
|
{news.status === 'APPROVED' ? '已发布' :
|
|
news.status === 'REJECTED' ? '已拒绝' : '待审核'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<div className="flex justify-end gap-2">
|
|
{news.status === 'PENDING' && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-green-500 hover:text-green-600"
|
|
onClick={() => approveArticle(news.id)}
|
|
title="通过并发布"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-red-500 hover:text-red-600"
|
|
onClick={() => rejectArticle(news.id)}
|
|
title="拒绝"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
{news.url && (
|
|
<a
|
|
href={news.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-gray-400 hover:text-[#FF6600] p-2"
|
|
title="查看原文"
|
|
>
|
|
<Eye className="w-4 h-4" />
|
|
</a>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-gray-300 hover:text-red-500"
|
|
onClick={() => deleteArticle(news.id)}
|
|
title="删除"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|