feat: bulk post controls - post N or all pending articles from admin UI
This commit is contained in:
parent
1064f3e284
commit
2aa4eb77cf
|
|
@ -657,6 +657,9 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
const [stats, setStats] = useState<ArticleStats | null>(null);
|
const [stats, setStats] = useState<ArticleStats | null>(null);
|
||||||
const [tab, setTab] = useState<PipelineTab>('discovered');
|
const [tab, setTab] = useState<PipelineTab>('discovered');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [bulkCount, setBulkCount] = useState(5);
|
||||||
|
const [posting, setPosting] = useState(false);
|
||||||
|
const [postResult, setPostResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
const fetchTab = async (t: PipelineTab) => {
|
const fetchTab = async (t: PipelineTab) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -676,6 +679,24 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
|
|
||||||
useEffect(() => { fetchTab(tab); }, [tab]);
|
useEffect(() => { fetchTab(tab); }, [tab]);
|
||||||
|
|
||||||
|
const handleBulkPost = async (count: number | 'all') => {
|
||||||
|
setPosting(true);
|
||||||
|
setPostResult(null);
|
||||||
|
try {
|
||||||
|
const resp = await api.triggerOfficialPost(configId, count);
|
||||||
|
const n = resp.posted?.length || 0;
|
||||||
|
const errs = resp.errors?.length || 0;
|
||||||
|
let msg = `Posted ${n} article(s)`;
|
||||||
|
if (errs > 0) msg += `, ${errs} error(s)`;
|
||||||
|
setPostResult({ ok: errs === 0, msg });
|
||||||
|
if (resp.stats) setStats(resp.stats);
|
||||||
|
fetchTab(tab);
|
||||||
|
} catch (e: any) {
|
||||||
|
setPostResult({ ok: false, msg: e.message });
|
||||||
|
}
|
||||||
|
setPosting(false);
|
||||||
|
};
|
||||||
|
|
||||||
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
|
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
|
||||||
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
|
{ 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: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
|
||||||
|
|
@ -714,6 +735,27 @@ function ArticlesPanel({ configId }: { configId: string }) {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bulk post controls — only on discovered tab */}
|
||||||
|
{tab === 'discovered' && stats && stats.discovered > 0 && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 bg-blue-50 border-b border-warm-200">
|
||||||
|
<span className="text-[10px] font-medium text-gray-600">Post:</span>
|
||||||
|
<input type="number" min={1} max={stats.discovered} value={bulkCount}
|
||||||
|
onChange={(e) => setBulkCount(Math.max(1, Number(e.target.value)))}
|
||||||
|
className="w-14 px-1.5 py-1 border border-warm-300 rounded text-xs text-center" />
|
||||||
|
<button onClick={() => handleBulkPost(bulkCount)} disabled={posting}
|
||||||
|
className="px-2.5 py-1 bg-brand-500 text-white rounded text-xs font-medium hover:bg-brand-600 disabled:opacity-50">
|
||||||
|
{posting ? 'Posting...' : `Post ${bulkCount}`}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleBulkPost('all')} disabled={posting}
|
||||||
|
className="px-2.5 py-1 bg-green-600 text-white rounded text-xs font-medium hover:bg-green-700 disabled:opacity-50">
|
||||||
|
{posting ? 'Posting...' : `Post All (${stats.discovered})`}
|
||||||
|
</button>
|
||||||
|
{postResult && (
|
||||||
|
<span className={`text-xs ml-auto ${postResult.ok ? 'text-green-600' : 'text-red-600'}`}>{postResult.msg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Article list */}
|
{/* Article list */}
|
||||||
<div className="max-h-56 overflow-y-auto">
|
<div className="max-h-56 overflow-y-auto">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
|
|
|
||||||
|
|
@ -425,8 +425,9 @@ class ApiClient {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async triggerOfficialPost(id: string) {
|
async triggerOfficialPost(id: string, count?: number | 'all') {
|
||||||
return this.request<any>(`/api/v1/admin/official-accounts/${id}/trigger`, { method: 'POST' });
|
const q = count !== undefined ? `?count=${count}` : '';
|
||||||
|
return this.request<any>(`/api/v1/admin/official-accounts/${id}/trigger${q}`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async previewOfficialPost(id: string) {
|
async previewOfficialPost(id: string) {
|
||||||
|
|
|
||||||
|
|
@ -2970,7 +2970,9 @@ func (h *AdminHandler) ToggleOfficialAccount(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"enabled": req.Enabled})
|
c.JSON(http.StatusOK, gin.H{"enabled": req.Enabled})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manually trigger a post for an official account
|
// Manually trigger a post for an official account.
|
||||||
|
// Accepts query param ?count=N to post multiple articles at once.
|
||||||
|
// count=0 or count=all posts ALL pending articles.
|
||||||
func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) {
|
func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
@ -2981,37 +2983,69 @@ func (h *AdminHandler) TriggerOfficialPost(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse count param: default 1, "all" or "0" = post everything
|
||||||
|
countStr := c.DefaultQuery("count", "1")
|
||||||
|
postAll := countStr == "all" || countStr == "0"
|
||||||
|
count := 1
|
||||||
|
if !postAll {
|
||||||
|
if n, err := strconv.Atoi(countStr); err == nil && n > 0 {
|
||||||
|
count = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch cfg.AccountType {
|
switch cfg.AccountType {
|
||||||
case "news", "rss":
|
case "news", "rss":
|
||||||
// Phase 1: Discover new articles
|
// Phase 1: Discover new articles
|
||||||
_, discErr := h.officialAccountsService.DiscoverArticles(ctx, id)
|
discovered, discErr := h.officialAccountsService.DiscoverArticles(ctx, id)
|
||||||
if discErr != nil {
|
if discErr != nil {
|
||||||
// Log but continue — there may be previously discovered articles
|
// Log but continue — there may be previously discovered articles
|
||||||
|
log.Warn().Err(discErr).Str("config", id).Msg("Discover error during trigger")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: Post next article from the queue
|
// If posting all, get the pending count
|
||||||
article, postID, err := h.officialAccountsService.PostNextArticle(ctx, id)
|
if postAll {
|
||||||
if err != nil {
|
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
if stats != nil {
|
||||||
return
|
count = stats.Discovered
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if article == nil {
|
|
||||||
|
// Phase 2: Post N articles from the queue
|
||||||
|
var posted []gin.H
|
||||||
|
var errors []string
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
article, postID, err := h.officialAccountsService.PostNextArticle(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, err.Error())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if article == nil {
|
||||||
|
break // no more articles
|
||||||
|
}
|
||||||
|
posted = append(posted, gin.H{
|
||||||
|
"post_id": postID,
|
||||||
|
"title": article.Title,
|
||||||
|
"link": article.Link,
|
||||||
|
"source": article.SourceName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(posted) == 0 && len(errors) == 0 {
|
||||||
msg := "No new articles found"
|
msg := "No new articles found"
|
||||||
if discErr != nil {
|
if discErr != nil {
|
||||||
msg += " (discover error: " + discErr.Error() + ")"
|
msg += " (discover error: " + discErr.Error() + ")"
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": nil})
|
c.JSON(http.StatusOK, gin.H{"message": msg, "post_id": nil, "discovered": discovered})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
stats, _ := h.officialAccountsService.GetArticleStats(ctx, id)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Article posted",
|
"message": fmt.Sprintf("Posted %d article(s)", len(posted)),
|
||||||
"post_id": postID,
|
"posted": posted,
|
||||||
"body": article.Link,
|
"errors": errors,
|
||||||
"source": article.SourceName,
|
"discovered": discovered,
|
||||||
"title": article.Title,
|
"stats": stats,
|
||||||
"stats": stats,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue