feat: article pipeline UI with status tabs + stats bar, backend status filter endpoint
This commit is contained in:
parent
ebbe8d92d1
commit
541a409806
|
|
@ -648,81 +648,103 @@ function EditAccountForm({ config, onDone }: { config: Config; onDone: () => voi
|
|||
);
|
||||
}
|
||||
|
||||
// ─── Articles Panel (news accounts) ──────────────────
|
||||
// ─── Articles Pipeline Panel ─────────────────────────
|
||||
type PipelineTab = 'discovered' | 'posted' | 'failed' | 'skipped';
|
||||
interface ArticleStats { discovered: number; posted: number; failed: number; skipped: number; total: number; }
|
||||
|
||||
function ArticlesPanel({ configId }: { configId: string }) {
|
||||
const [articles, setArticles] = useState<any[]>([]);
|
||||
const [posted, setPosted] = useState<any[]>([]);
|
||||
const [tab, setTab] = useState<'pending' | 'posted'>('pending');
|
||||
const [stats, setStats] = useState<ArticleStats | null>(null);
|
||||
const [tab, setTab] = useState<PipelineTab>('discovered');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchPending = async () => {
|
||||
const fetchTab = async (t: PipelineTab) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.fetchNewsArticles(configId);
|
||||
setArticles(data.articles || []);
|
||||
if (t === 'discovered') {
|
||||
const data = await api.fetchNewsArticles(configId);
|
||||
setArticles(data.articles || []);
|
||||
if (data.stats) setStats(data.stats);
|
||||
} else {
|
||||
const data = await api.getPostedArticles(configId, 50, t);
|
||||
setArticles(data.articles || []);
|
||||
if (data.stats) setStats(data.stats);
|
||||
}
|
||||
} catch { setArticles([]); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchPosted = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.getPostedArticles(configId);
|
||||
setPosted(data.articles || []);
|
||||
} catch { setPosted([]); }
|
||||
setLoading(false);
|
||||
};
|
||||
useEffect(() => { fetchTab(tab); }, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === 'pending') fetchPending();
|
||||
else fetchPosted();
|
||||
}, [tab]);
|
||||
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
|
||||
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||
{ key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||
{ key: 'failed', label: 'Failed', color: 'text-red-700', bgColor: 'bg-red-100' },
|
||||
{ key: 'skipped', label: 'Skipped', color: 'text-gray-600', bgColor: 'bg-gray-100' },
|
||||
];
|
||||
|
||||
const getCount = (key: PipelineTab) => stats ? stats[key] : 0;
|
||||
|
||||
return (
|
||||
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
|
||||
{/* Stats bar */}
|
||||
{stats && (
|
||||
<div className="flex items-center gap-3 px-3 py-2 bg-warm-50 border-b border-warm-200">
|
||||
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">Pipeline</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-medium">{stats.discovered} pending</span>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-medium">{stats.posted} posted</span>
|
||||
{stats.failed > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-medium">{stats.failed} failed</span>}
|
||||
{stats.skipped > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{stats.skipped} skipped</span>}
|
||||
<span className="text-[10px] text-gray-400 ml-auto">{stats.total} total</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex bg-warm-100">
|
||||
<button onClick={() => setTab('pending')}
|
||||
className={`px-3 py-1.5 text-xs font-medium ${tab === 'pending' ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500'}`}>
|
||||
Pending Articles
|
||||
</button>
|
||||
<button onClick={() => setTab('posted')}
|
||||
className={`px-3 py-1.5 text-xs font-medium ${tab === 'posted' ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500'}`}>
|
||||
Posted History
|
||||
</button>
|
||||
{tabConfig.map((t) => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)}
|
||||
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
tab === t.key ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500 hover:text-gray-700'
|
||||
}`}>
|
||||
{t.label}
|
||||
{stats && getCount(t.key) > 0 && (
|
||||
<span className={`ml-1.5 text-[10px] px-1 py-0.5 rounded ${t.bgColor} ${t.color}`}>{getCount(t.key)}</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
|
||||
{/* Article list */}
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="p-3 text-xs text-gray-500">Loading...</p>
|
||||
) : tab === 'pending' ? (
|
||||
articles.length === 0 ? (
|
||||
<p className="p-3 text-xs text-gray-500">No pending articles</p>
|
||||
) : (
|
||||
articles.map((a, i) => (
|
||||
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600">{a.source}</span>
|
||||
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1">
|
||||
{a.title} <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
{a.description && <p className="text-[10px] text-gray-500 mt-0.5">{a.description}</p>}
|
||||
) : articles.length === 0 ? (
|
||||
<p className="p-3 text-xs text-gray-500">No {tab} articles</p>
|
||||
) : tab === 'discovered' ? (
|
||||
articles.map((a, i) => (
|
||||
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source}</span>
|
||||
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
|
||||
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
{a.description && <p className="text-[10px] text-gray-500 mt-0.5 line-clamp-2">{a.description}</p>}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
posted.length === 0 ? (
|
||||
<p className="p-3 text-xs text-gray-500">No posted articles yet</p>
|
||||
) : (
|
||||
posted.map((a) => (
|
||||
<div key={a.id} className="p-2 border-b border-warm-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600">{a.source_name}</span>
|
||||
<span className="text-xs font-medium text-gray-700">{a.article_title}</span>
|
||||
<span className="text-[10px] text-gray-400 ml-auto">{new Date(a.posted_at).toLocaleString()}</span>
|
||||
</div>
|
||||
articles.map((a) => (
|
||||
<div key={a.id} className="p-2 border-b border-warm-100 last:border-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source_name}</span>
|
||||
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
|
||||
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||
</a>
|
||||
{a.posted_at && <span className="text-[10px] text-gray-400 ml-auto flex-shrink-0">{new Date(a.posted_at).toLocaleString()}</span>}
|
||||
</div>
|
||||
))
|
||||
)
|
||||
{a.error_message && <p className="text-[10px] text-red-500 mt-0.5">{a.error_message}</p>}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -437,8 +437,8 @@ class ApiClient {
|
|||
return this.request<any>(`/api/v1/admin/official-accounts/${id}/articles`);
|
||||
}
|
||||
|
||||
async getPostedArticles(id: string, limit = 50) {
|
||||
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}`);
|
||||
async getPostedArticles(id: string, limit = 50, status = 'posted') {
|
||||
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`);
|
||||
}
|
||||
|
||||
async adminImportContent(data: {
|
||||
|
|
|
|||
|
|
@ -3113,9 +3113,10 @@ func (h *AdminHandler) FetchNewsArticles(c *gin.Context) {
|
|||
c.JSON(http.StatusOK, gin.H{"articles": previews, "count": len(previews), "stats": stats})
|
||||
}
|
||||
|
||||
// Get posted articles history for an account
|
||||
// Get articles by status for an account (defaults to "posted")
|
||||
func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
status := c.DefaultQuery("status", "posted")
|
||||
limit := 50
|
||||
if l := c.Query("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
|
|
@ -3123,7 +3124,7 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
|||
}
|
||||
}
|
||||
|
||||
articles, err := h.officialAccountsService.GetRecentArticles(c.Request.Context(), id, limit)
|
||||
articles, err := h.officialAccountsService.GetArticleQueue(c.Request.Context(), id, status, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
|
@ -3131,7 +3132,8 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
|||
if articles == nil {
|
||||
articles = []services.CachedArticle{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"articles": articles})
|
||||
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), id)
|
||||
c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats})
|
||||
}
|
||||
|
||||
// ── Safe Domains Management ─────────────────────────
|
||||
|
|
|
|||
Loading…
Reference in a new issue