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 }) {
|
function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
const [articles, setArticles] = useState<any[]>([]);
|
const [articles, setArticles] = useState<any[]>([]);
|
||||||
const [posted, setPosted] = useState<any[]>([]);
|
const [stats, setStats] = useState<ArticleStats | null>(null);
|
||||||
const [tab, setTab] = useState<'pending' | 'posted'>('pending');
|
const [tab, setTab] = useState<PipelineTab>('discovered');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const fetchPending = async () => {
|
const fetchTab = async (t: PipelineTab) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
|
if (t === 'discovered') {
|
||||||
const data = await api.fetchNewsArticles(configId);
|
const data = await api.fetchNewsArticles(configId);
|
||||||
setArticles(data.articles || []);
|
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([]); }
|
} catch { setArticles([]); }
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchPosted = async () => {
|
useEffect(() => { fetchTab(tab); }, [tab]);
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const data = await api.getPostedArticles(configId);
|
|
||||||
setPosted(data.articles || []);
|
|
||||||
} catch { setPosted([]); }
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
|
||||||
if (tab === 'pending') fetchPending();
|
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
||||||
else fetchPosted();
|
{ key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||||
}, [tab]);
|
{ 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 (
|
return (
|
||||||
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
|
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
|
||||||
<div className="flex bg-warm-100">
|
{/* Stats bar */}
|
||||||
<button onClick={() => setTab('pending')}
|
{stats && (
|
||||||
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'}`}>
|
<div className="flex items-center gap-3 px-3 py-2 bg-warm-50 border-b border-warm-200">
|
||||||
Pending Articles
|
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">Pipeline</span>
|
||||||
</button>
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-medium">{stats.discovered} pending</span>
|
||||||
<button onClick={() => setTab('posted')}
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-medium">{stats.posted} posted</span>
|
||||||
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'}`}>
|
{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>}
|
||||||
Posted History
|
{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>}
|
||||||
</button>
|
<span className="text-[10px] text-gray-400 ml-auto">{stats.total} total</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-48 overflow-y-auto">
|
)}
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex bg-warm-100">
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* Article list */}
|
||||||
|
<div className="max-h-56 overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="p-3 text-xs text-gray-500">Loading...</p>
|
<p className="p-3 text-xs text-gray-500">Loading...</p>
|
||||||
) : tab === 'pending' ? (
|
) : articles.length === 0 ? (
|
||||||
articles.length === 0 ? (
|
<p className="p-3 text-xs text-gray-500">No {tab} articles</p>
|
||||||
<p className="p-3 text-xs text-gray-500">No pending articles</p>
|
) : tab === 'discovered' ? (
|
||||||
) : (
|
|
||||||
articles.map((a, i) => (
|
articles.map((a, i) => (
|
||||||
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
|
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
<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">
|
<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" />
|
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{a.description && <p className="text-[10px] text-gray-500 mt-0.5">{a.description}</p>}
|
{a.description && <p className="text-[10px] text-gray-500 mt-0.5 line-clamp-2">{a.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
posted.length === 0 ? (
|
articles.map((a) => (
|
||||||
<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 key={a.id} className="p-2 border-b border-warm-100 last:border-0">
|
||||||
<div className="flex items-center gap-2">
|
<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-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source_name}</span>
|
||||||
<span className="text-xs font-medium text-gray-700">{a.article_title}</span>
|
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
|
||||||
<span className="text-[10px] text-gray-400 ml-auto">{new Date(a.posted_at).toLocaleString()}</span>
|
{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>
|
</div>
|
||||||
|
{a.error_message && <p className="text-[10px] text-red-500 mt-0.5">{a.error_message}</p>}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -437,8 +437,8 @@ class ApiClient {
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/${id}/articles`);
|
return this.request<any>(`/api/v1/admin/official-accounts/${id}/articles`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPostedArticles(id: string, limit = 50) {
|
async getPostedArticles(id: string, limit = 50, status = 'posted') {
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}`);
|
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async adminImportContent(data: {
|
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})
|
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) {
|
func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
|
status := c.DefaultQuery("status", "posted")
|
||||||
limit := 50
|
limit := 50
|
||||||
if l := c.Query("limit"); l != "" {
|
if l := c.Query("limit"); l != "" {
|
||||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|
@ -3131,7 +3132,8 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
|
||||||
if articles == nil {
|
if articles == nil {
|
||||||
articles = []services.CachedArticle{}
|
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 ─────────────────────────
|
// ── Safe Domains Management ─────────────────────────
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue