feat: paginate and search brand questions across selects
Frontend CI / Frontend (push) Successful in 3m19s
Backend CI / Backend (push) Successful in 14m30s

Make GET /api/tenant/brands/:id/questions return a paginated
QuestionListResponse (items/total/page/page_size) with optional `q`
full-text filter, validated page/page_size query params, and per-params
cache keys. Add a usePaginatedBrandQuestions composable backing the
imitation, template-wizard, and tracking question selects with
search-as-you-type and infinite scroll, plus ensure-loaded-by-id so a
deep-linked or pre-selected question is fetched even when off the first
page. Brands view now drives its questions table via server pagination.

Also redesign the Tracking hot-questions and cited-articles lists
(mention-rate badges/bars, metric groups, refreshed styling) and fall
back to citation-fact article_id/title when no high-confidence URL alias
matches, so cited articles surface even without an alias row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:01:23 +08:00
parent 184ebfc4c2
commit 9ed857e159
13 changed files with 1030 additions and 189 deletions
@@ -3639,7 +3639,9 @@ func (s *MonitoringService) loadCitedArticles(
rows, err := s.monitoringPool.Query(ctx, `
SELECT
cf.cited_url,
cf.normalized_url
cf.normalized_url,
NULLIF(cf.article_id, 0),
NULLIF(cf.cited_title, '')
FROM monitoring_citation_facts cf
JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
@@ -3668,22 +3670,40 @@ func (s *MonitoringService) loadCitedArticles(
for rows.Next() {
var citedURL string
var normalizedURL string
if scanErr := rows.Scan(&citedURL, &normalizedURL); scanErr != nil {
var factArticleID sql.NullInt64
var factCitedTitle sql.NullString
if scanErr := rows.Scan(&citedURL, &normalizedURL, &factArticleID, &factCitedTitle); scanErr != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
}
alias, ok := aliasIndex.Match(citedURL, normalizedURL)
if !ok {
articleID := factArticleID.Int64
if !factArticleID.Valid && ok {
articleID = alias.ArticleID
}
if articleID <= 0 {
continue
}
item := aggregates[alias.ArticleID]
item := aggregates[articleID]
if item.ArticleID == 0 {
item.ArticleID = alias.ArticleID
item.ArticleTitle = alias.ArticleTitle
item.PublishPlatform = alias.PublishPlatform
item.ArticleID = articleID
if ok && strings.TrimSpace(alias.ArticleTitle) != "" {
item.ArticleTitle = alias.ArticleTitle
} else if factCitedTitle.Valid && strings.TrimSpace(factCitedTitle.String) != "" {
item.ArticleTitle = factCitedTitle.String
} else {
item.ArticleTitle = "未命名文章"
}
if ok && strings.TrimSpace(alias.PublishPlatform) != "" {
item.PublishPlatform = alias.PublishPlatform
} else {
item.PublishPlatform = "已发布内容"
}
}
item.CitationCount++
totalArticleCitationCount++
aggregates[alias.ArticleID] = item
aggregates[articleID] = item
}
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
@@ -3748,11 +3768,12 @@ func (s *MonitoringService) loadCitedArticlesPage(
var stats citedArticleStats
if err := s.monitoringPool.QueryRow(ctx, `
WITH article_counts AS (
SELECT alias.article_id, COUNT(*)::bigint AS citation_count
SELECT COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS article_id,
COUNT(*)::bigint AS citation_count
FROM monitoring_citation_facts cf
JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
JOIN monitoring_article_url_aliases alias
LEFT JOIN monitoring_article_url_aliases alias
ON alias.tenant_id = cf.tenant_id
AND alias.normalized_url = cf.normalized_url
AND alias.confidence = 'high'
@@ -3764,7 +3785,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY alias.article_id
AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
)
SELECT COUNT(*)::bigint, COALESCE(SUM(citation_count), 0)::bigint
FROM article_counts
@@ -3780,14 +3802,14 @@ func (s *MonitoringService) loadCitedArticlesPage(
rows, err := s.monitoringPool.Query(ctx, `
WITH article_counts AS (
SELECT
alias.article_id,
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') AS article_title,
COALESCE(NULLIF(cf.article_id, 0), alias.article_id) AS article_id,
COALESCE(MAX(alias.article_title_snapshot), MAX(NULLIF(cf.cited_title, '')), '未命名文章') AS article_title,
COALESCE(MAX(alias.publish_platform_name_snapshot), '已发布内容') AS publish_platform,
COUNT(*)::bigint AS citation_count
FROM monitoring_citation_facts cf
JOIN question_monitor_runs r
ON r.tenant_id = cf.tenant_id AND r.id = cf.run_id
JOIN monitoring_article_url_aliases alias
LEFT JOIN monitoring_article_url_aliases alias
ON alias.tenant_id = cf.tenant_id
AND alias.normalized_url = cf.normalized_url
AND alias.confidence = 'high'
@@ -3799,7 +3821,8 @@ func (s *MonitoringService) loadCitedArticlesPage(
AND r.business_date BETWEEN $4::date AND $5::date
AND ($6::bigint[] IS NULL OR r.question_id = ANY($6))
AND ($7::text[] IS NULL OR r.ai_platform_id = ANY($7))
GROUP BY alias.article_id
AND COALESCE(NULLIF(cf.article_id, 0), alias.article_id) IS NOT NULL
GROUP BY COALESCE(NULLIF(cf.article_id, 0), alias.article_id)
)
SELECT article_id, article_title, publish_platform, citation_count
FROM article_counts