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
+79 -8
View File
@@ -474,10 +474,44 @@ type QuestionResponse struct {
CreatedAt string `json:"created_at"`
}
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
type QuestionListParams struct {
KeywordID *int64
Page int
PageSize int
Query string
}
type QuestionListResponse struct {
Items []QuestionResponse `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
func normalizeQuestionListParams(params QuestionListParams) QuestionListParams {
params.Query = strings.TrimSpace(params.Query)
if params.Page < 1 {
params.Page = 1
}
if params.PageSize < 1 {
params.PageSize = 10
}
if params.PageSize > 100 {
params.PageSize = 100
}
return params
}
func (params QuestionListParams) offset() int {
params = normalizeQuestionListParams(params)
return (params.Page - 1) * params.PageSize
}
func (s *BrandService) ListQuestions(ctx context.Context, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
actor := auth.MustActor(ctx)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, keywordID), defaultCacheTTL(), func(loadCtx context.Context) ([]QuestionResponse, error) {
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, keywordID)
params = normalizeQuestionListParams(params)
return sharedcache.LoadJSON(ctx, s.cache, &s.cacheGroup, brandQuestionsCacheKey(actor.TenantID, brandID, params), defaultCacheTTL(), func(loadCtx context.Context) (*QuestionListResponse, error) {
return s.loadBrandQuestions(loadCtx, actor.TenantID, brandID, params)
})
}
@@ -857,18 +891,44 @@ func (s *BrandService) loadBrandKeywords(ctx context.Context, tenantID, brandID
return items, nil
}
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, keywordID *int64) ([]QuestionResponse, error) {
func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID int64, params QuestionListParams) (*QuestionListResponse, error) {
params = normalizeQuestionListParams(params)
countQuery := `
SELECT COUNT(*)::bigint
FROM brand_questions q
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
countArgs := []interface{}{brandID, tenantID}
if params.KeywordID != nil {
countQuery += ` AND q.keyword_id = $3`
countArgs = append(countArgs, *params.KeywordID)
}
if params.Query != "" {
countArgs = append(countArgs, "%"+params.Query+"%")
countQuery += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(countArgs))
}
var total int64
if err := s.pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
return nil, response.ErrInternal(50010, "query_failed", "failed to count questions")
}
query := `
SELECT q.id, q.brand_id, q.keyword_id, q.question_text, q.status, q.created_at
FROM brand_questions q
WHERE q.brand_id = $1 AND q.tenant_id = $2 AND q.deleted_at IS NULL`
args := []interface{}{brandID, tenantID}
if keywordID != nil {
if params.KeywordID != nil {
query += ` AND q.keyword_id = $3`
args = append(args, *keywordID)
args = append(args, *params.KeywordID)
}
query += ` ORDER BY q.created_at DESC`
if params.Query != "" {
args = append(args, "%"+params.Query+"%")
query += fmt.Sprintf(` AND q.question_text ILIKE $%d`, len(args))
}
query += fmt.Sprintf(` ORDER BY q.created_at DESC LIMIT $%d OFFSET $%d`, len(args)+1, len(args)+2)
args = append(args, params.PageSize, params.offset())
rows, err := s.pool.Query(ctx, query, args...)
if err != nil {
@@ -886,7 +946,18 @@ func (s *BrandService) loadBrandQuestions(ctx context.Context, tenantID, brandID
item.CreatedAt = fmt.Sprintf("%v", createdAt)
items = append(items, item)
}
return items, nil
if err := rows.Err(); err != nil {
return nil, response.ErrInternal(50010, "scan_failed", err.Error())
}
if items == nil {
items = []QuestionResponse{}
}
return &QuestionListResponse{
Items: items,
Total: total,
Page: params.Page,
PageSize: params.PageSize,
}, nil
}
func (s *BrandService) loadBrandCompetitors(ctx context.Context, tenantID, brandID int64) ([]CompetitorResponse, error) {