feat: paginate tracking lists server-side
Frontend CI / Frontend (push) Successful in 3m19s
Backend CI / Backend (push) Failing after 6m43s

This commit is contained in:
2026-06-15 14:57:39 +08:00
parent cc8794b1d1
commit 0ab1013984
7 changed files with 507 additions and 28 deletions
@@ -571,6 +571,7 @@ const enUS = {
hotQuestionsHint: hotQuestionsHint:
'Choose the full keyword library (search terms) or one specific search term. Selecting one search term limits display and collection to that term.', 'Choose the full keyword library (search terms) or one specific search term. Selecting one search term limits display and collection to that term.',
questionSearchPlaceholder: 'Search questions in the selected scope', questionSearchPlaceholder: 'Search questions in the selected scope',
noQuestionSearchResults: 'No matching questions',
questionDetailEyebrow: 'Question Detail', questionDetailEyebrow: 'Question Detail',
questionDetailCollectedAt: 'Collected at {time}', questionDetailCollectedAt: 'Collected at {time}',
questionDetailFallback: 'Question Detail', questionDetailFallback: 'Question Detail',
@@ -599,6 +600,7 @@ const enUS = {
noQuestions: 'No hot questions for the current filters', noQuestions: 'No hot questions for the current filters',
noCitationRanking: 'No attributable citations in the current window', noCitationRanking: 'No attributable citations in the current window',
noCitedArticles: 'No content citations in the current window', noCitedArticles: 'No content citations in the current window',
viewArticleContent: 'View article content',
noAnswer: 'No sampled answer for this platform', noAnswer: 'No sampled answer for this platform',
noCitations: 'No citations for this platform', noCitations: 'No citations for this platform',
noCitationAnalysis: 'No citation analysis for this platform', noCitationAnalysis: 'No citation analysis for this platform',
@@ -554,6 +554,7 @@ const zhCN = {
hotQuestionsHint: hotQuestionsHint:
'可选择全部关键词库(搜索词)或具体单个搜索词;选择单个搜索词时只展示并采集该搜索词。', '可选择全部关键词库(搜索词)或具体单个搜索词;选择单个搜索词时只展示并采集该搜索词。',
questionSearchPlaceholder: '搜索当前范围下的问题', questionSearchPlaceholder: '搜索当前范围下的问题',
noQuestionSearchResults: '没有匹配的问题',
questionDetailEyebrow: 'Question Detail', questionDetailEyebrow: 'Question Detail',
questionDetailCollectedAt: '采集时间 {time}', questionDetailCollectedAt: '采集时间 {time}',
questionDetailFallback: '问题详情', questionDetailFallback: '问题详情',
@@ -580,6 +581,7 @@ const zhCN = {
noQuestions: '当前筛选条件下暂无高频问题', noQuestions: '当前筛选条件下暂无高频问题',
noCitationRanking: '当前时间窗口内暂无可归因引用', noCitationRanking: '当前时间窗口内暂无可归因引用',
noCitedArticles: '当前时间窗口内暂无内容引用', noCitedArticles: '当前时间窗口内暂无内容引用',
viewArticleContent: '查看文章内容',
noAnswer: '该平台暂无采样回答', noAnswer: '该平台暂无采样回答',
noCitations: '该平台无引用来源', noCitations: '该平台无引用来源',
noCitationAnalysis: '该平台暂无引用分析', noCitationAnalysis: '该平台暂无引用分析',
+5
View File
@@ -1501,6 +1501,9 @@ export const monitoringApi = {
days?: number days?: number
business_date?: string business_date?: string
ai_platform_id?: string ai_platform_id?: string
hot_questions_page?: number
hot_questions_page_size?: number
hot_questions_q?: string
}) { }) {
return apiClient.get<MonitoringDashboardCompositeResponse>( return apiClient.get<MonitoringDashboardCompositeResponse>(
'/api/tenant/monitoring/dashboard/composite', '/api/tenant/monitoring/dashboard/composite',
@@ -1516,6 +1519,8 @@ export const monitoringApi = {
question_id?: number | null question_id?: number | null
business_date?: string business_date?: string
ai_platform_id?: string ai_platform_id?: string
cited_articles_page?: number
cited_articles_page_size?: number
}) { }) {
return apiClient.get<MonitoringCitationSummaryResponse>( return apiClient.get<MonitoringCitationSummaryResponse>(
'/api/tenant/monitoring/citation-summary', '/api/tenant/monitoring/citation-summary',
+176 -16
View File
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ReloadOutlined } from '@ant-design/icons-vue' import { EyeOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import type { import type {
DesktopAccountInfo, DesktopAccountInfo,
MonitoringCitedArticle,
MonitoringHotQuestion, MonitoringHotQuestion,
MonitoringPlatformAuthorizationStatus, MonitoringPlatformAuthorizationStatus,
} from '@geo/shared-types' } from '@geo/shared-types'
@@ -14,6 +15,7 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import ArticleDetailDrawer from '@/components/ArticleDetailDrawer.vue'
import PageHero from '@/components/PageHero.vue' import PageHero from '@/components/PageHero.vue'
import { brandsApi, monitoringApi, tenantAccountsApi } from '@/lib/api' import { brandsApi, monitoringApi, tenantAccountsApi } from '@/lib/api'
import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime' import { resolveAccountCheckedAt, resolveAccountHealth } from '@/lib/desktop-account-runtime'
@@ -34,6 +36,7 @@ const companyStore = useCompanyStore()
const trackingMaxHistoryDays = 30 const trackingMaxHistoryDays = 30
const trackingBusinessTimeZone = 'Asia/Shanghai' const trackingBusinessTimeZone = 'Asia/Shanghai'
const trackingToday = formatTrackingBusinessDate(new Date()) const trackingToday = formatTrackingBusinessDate(new Date())
const trackingListPageSize = 10
type CitationRankingRow = { type CitationRankingRow = {
ai_platform_id: string ai_platform_id: string
@@ -105,6 +108,11 @@ const selectedBusinessDate = ref<string>(
normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday, normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday,
) )
const selectedCitationWindowDays = useStorage<number>('tracking_selected_citation_window_days', 7) const selectedCitationWindowDays = useStorage<number>('tracking_selected_citation_window_days', 7)
const hotQuestionSearch = ref('')
const hotQuestionPage = ref(1)
const citedArticlePage = ref(1)
const articleDrawerOpen = ref(false)
const selectedArticleId = ref<number | null>(null)
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value) selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value)
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value) selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value)
@@ -239,6 +247,8 @@ const dashboardQuery = useQuery({
selectedQuestionId.value, selectedQuestionId.value,
selectedPlatformId.value, selectedPlatformId.value,
selectedBusinessDate.value, selectedBusinessDate.value,
hotQuestionPage.value,
hotQuestionSearch.value.trim(),
]), ]),
enabled: computed(() => Boolean(selectedBrandId.value)), enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => queryFn: () =>
@@ -248,6 +258,9 @@ const dashboardQuery = useQuery({
days: trackingMaxHistoryDays, days: trackingMaxHistoryDays,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined, ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
business_date: selectedBusinessDate.value, business_date: selectedBusinessDate.value,
hot_questions_page: hotQuestionPage.value,
hot_questions_page_size: trackingListPageSize,
hot_questions_q: hotQuestionSearch.value.trim() || undefined,
}), }),
}) })
@@ -260,6 +273,7 @@ const citationSummaryQuery = useQuery({
selectedPlatformId.value, selectedPlatformId.value,
selectedBusinessDate.value, selectedBusinessDate.value,
selectedCitationWindowDays.value, selectedCitationWindowDays.value,
citedArticlePage.value,
]), ]),
enabled: computed(() => Boolean(selectedBrandId.value)), enabled: computed(() => Boolean(selectedBrandId.value)),
queryFn: () => queryFn: () =>
@@ -269,6 +283,8 @@ const citationSummaryQuery = useQuery({
business_date: selectedBusinessDate.value, business_date: selectedBusinessDate.value,
ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined, ai_platform_id: selectedPlatformId.value !== 'all' ? selectedPlatformId.value : undefined,
days: selectedCitationWindowDays.value, days: selectedCitationWindowDays.value,
cited_articles_page: citedArticlePage.value,
cited_articles_page_size: trackingListPageSize,
}), }),
}) })
@@ -375,6 +391,12 @@ const hotQuestions = computed(() => {
return dashboardQuery.data.value?.hot_questions ?? [] return dashboardQuery.data.value?.hot_questions ?? []
}) })
const hotQuestionsPageInfo = computed(() => {
return dashboardQuery.data.value?.hot_questions_page
})
const hotQuestionsTotal = computed(() => hotQuestionsPageInfo.value?.total ?? 0)
const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => { const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => {
const accountGroups = new Map<string, DesktopAccountInfo[]>() const accountGroups = new Map<string, DesktopAccountInfo[]>()
for (const account of aiAccountsQuery.data.value ?? []) { for (const account of aiAccountsQuery.data.value ?? []) {
@@ -541,10 +563,13 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
}) })
}) })
const visibleCitedArticles = computed(() => { const citedArticles = computed(() => {
return citationSummaryQuery.data.value?.cited_articles ?? [] return citationSummaryQuery.data.value?.cited_articles ?? []
}) })
const citedArticlesPageInfo = computed(() => citationSummaryQuery.data.value?.cited_articles_page)
const citedArticlesTotal = computed(() => citedArticlesPageInfo.value?.total ?? 0)
const citationPieSlices = computed(() => { const citationPieSlices = computed(() => {
const segments = citationRankingRows.value.filter( const segments = citationRankingRows.value.filter(
(item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0, (item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0,
@@ -624,6 +649,18 @@ const heroDescription = computed(() => {
return `最近一次采集数据:${formatDateTime(overview.last_sampled_at)}` return `最近一次采集数据:${formatDateTime(overview.last_sampled_at)}`
}) })
watch(hotQuestionSearch, () => {
hotQuestionPage.value = 1
})
watch([selectedBrandId, selectedQuestionId, selectedPlatformId, selectedBusinessDate], () => {
hotQuestionPage.value = 1
})
watch([selectedBrandId, selectedQuestionId, selectedPlatformId, selectedBusinessDate, selectedCitationWindowDays], () => {
citedArticlePage.value = 1
})
function openQuestion(question: MonitoringHotQuestion): void { function openQuestion(question: MonitoringHotQuestion): void {
if (!selectedBrandId.value) { if (!selectedBrandId.value) {
return return
@@ -646,6 +683,11 @@ function openQuestion(question: MonitoringHotQuestion): void {
}) })
} }
function openCitedArticle(item: MonitoringCitedArticle): void {
selectedArticleId.value = item.article_id
articleDrawerOpen.value = true
}
function filterQuestionOption(input: string, option?: { label?: string }): boolean { function filterQuestionOption(input: string, option?: { label?: string }): boolean {
const keyword = input.trim().toLowerCase() const keyword = input.trim().toLowerCase()
if (!keyword) { if (!keyword) {
@@ -1192,9 +1234,17 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
</a-card> </a-card>
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value"> <a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
<div class="tracking-panel__header"> <div class="tracking-panel__header tracking-panel__header--list">
<h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3> <div class="tracking-panel__header-left">
<span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span> <h3 class="panel-title">{{ t('tracking.hotQuestionsTitle') }}</h3>
<span class="tracking-panel__helper">{{ t('tracking.hotQuestionsHint') }}</span>
</div>
<a-input-search
v-model:value="hotQuestionSearch"
allow-clear
class="tracking-list-search"
:placeholder="t('tracking.questionSearchPlaceholder')"
/>
</div> </div>
<div v-if="hotQuestions.length" class="question-list"> <div v-if="hotQuestions.length" class="question-list">
@@ -1217,7 +1267,24 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
</button> </button>
</div> </div>
<a-empty v-else :description="t('tracking.noQuestions')" /> <a-empty
v-else
:description="
hotQuestionSearch.trim()
? t('tracking.noQuestionSearchResults')
: t('tracking.noQuestions')
"
/>
<div v-if="hotQuestionsTotal > trackingListPageSize" class="tracking-pagination">
<a-pagination
v-model:current="hotQuestionPage"
:page-size="trackingListPageSize"
:total="hotQuestionsTotal"
:show-size-changer="false"
size="small"
/>
</div>
</a-card> </a-card>
</div> </div>
@@ -1230,29 +1297,58 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
</span> </span>
</div> </div>
<div class="article-list"> <div v-if="citedArticles.length" class="article-list">
<div <div
v-for="item in visibleCitedArticles" v-for="item in citedArticles"
:key="item.article_id" :key="item.article_id"
class="article-list__item" class="article-list__item"
> >
<div> <div class="article-list__content">
<strong>{{ item.article_title }}</strong> <strong>{{ item.article_title }}</strong>
<p>{{ item.publish_platform }}</p> <p>{{ item.publish_platform }}</p>
</div> </div>
<div class="article-list__meta"> <div class="article-list__side">
<span>{{ item.citation_count }} {{ t('tracking.citations') }}</span> <div class="article-list__meta">
<span>{{ t('tracking.columns.share') }} {{ formatPercent(item.source_share) }}</span> <span>{{ item.citation_count }} {{ t('tracking.citations') }}</span>
<strong>{{ formatPercent(item.citation_rate) }}</strong> <span>
{{ t('tracking.columns.share') }} {{ formatPercent(item.source_share) }}
</span>
<strong>{{ formatPercent(item.citation_rate) }}</strong>
</div>
<a-tooltip :title="t('tracking.viewArticleContent')">
<a-button
type="text"
shape="circle"
class="article-list__preview-button"
:aria-label="t('tracking.viewArticleContent')"
@click="openCitedArticle(item)"
>
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
</div> </div>
</div> </div>
<a-empty </div>
v-if="!visibleCitedArticles.length"
:description="t('tracking.noCitedArticles')" <a-empty v-else :description="t('tracking.noCitedArticles')" />
<div v-if="citedArticlesTotal > trackingListPageSize" class="tracking-pagination">
<a-pagination
v-model:current="citedArticlePage"
:page-size="trackingListPageSize"
:total="citedArticlesTotal"
:show-size-changer="false"
size="small"
/> />
</div> </div>
</a-card> </a-card>
</div> </div>
<ArticleDetailDrawer
:open="articleDrawerOpen"
:article-id="selectedArticleId"
@close="articleDrawerOpen = false"
/>
</section> </section>
</template> </template>
@@ -1456,6 +1552,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
flex-direction: column; flex-direction: column;
} }
.tracking-panel__header--list {
align-items: flex-start;
}
.panel-title { .panel-title {
margin: 0; margin: 0;
font-size: 16px; font-size: 16px;
@@ -1486,6 +1586,11 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
font-size: 13px; font-size: 13px;
} }
.tracking-list-search {
width: min(100%, 260px);
flex: 0 0 260px;
}
.tracking-panel__empty { .tracking-panel__empty {
padding: 28px 0 16px; padding: 28px 0 16px;
} }
@@ -1831,6 +1936,37 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
gap: 6px; gap: 6px;
min-width: 84px;
}
.article-list__content {
min-width: 0;
}
.article-list__content strong {
overflow-wrap: anywhere;
}
.article-list__side {
display: flex;
align-items: center;
gap: 12px;
flex: 0 0 auto;
}
.article-list__preview-button {
color: #4b5563;
}
.article-list__preview-button:hover {
color: #1f5cff;
background: #eef4ff;
}
.tracking-pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
} }
.detail-tabs { .detail-tabs {
@@ -1911,6 +2047,30 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.tracking-panel__header--list {
flex-direction: column;
}
.tracking-list-search {
width: 100%;
flex-basis: auto;
}
.question-list__item,
.article-list__item {
align-items: flex-start;
flex-direction: column;
}
.question-list__time {
align-self: flex-end;
}
.article-list__side {
justify-content: space-between;
width: 100%;
}
.tracking-citation-header { .tracking-citation-header {
flex-direction: column; flex-direction: column;
} }
+9
View File
@@ -1902,6 +1902,12 @@ export interface MonitoringDashboardCitationWindow {
to: string to: string
} }
export interface MonitoringPage {
page: number
page_size: number
total: number
}
export type MonitoringPlatformAuthorizationStatus = export type MonitoringPlatformAuthorizationStatus =
| 'authorized' | 'authorized'
| 'no_desktop_client' | 'no_desktop_client'
@@ -1921,14 +1927,17 @@ export interface MonitoringDashboardCompositeResponse {
citation_window: MonitoringDashboardCitationWindow citation_window: MonitoringDashboardCitationWindow
platform_breakdown: MonitoringPlatformBreakdownItem[] platform_breakdown: MonitoringPlatformBreakdownItem[]
hot_questions: MonitoringHotQuestion[] hot_questions: MonitoringHotQuestion[]
hot_questions_page: MonitoringPage
citation_ranking: MonitoringCitationRankingItem[] citation_ranking: MonitoringCitationRankingItem[]
cited_articles: MonitoringCitedArticle[] cited_articles: MonitoringCitedArticle[]
cited_articles_page: MonitoringPage
} }
export interface MonitoringCitationSummaryResponse { export interface MonitoringCitationSummaryResponse {
citation_window: MonitoringDashboardCitationWindow citation_window: MonitoringDashboardCitationWindow
citation_ranking: MonitoringCitationRankingItem[] citation_ranking: MonitoringCitationRankingItem[]
cited_articles: MonitoringCitedArticle[] cited_articles: MonitoringCitedArticle[]
cited_articles_page: MonitoringPage
} }
export interface MonitoringQuestionDetailCitation { export interface MonitoringQuestionDetailCitation {
+279 -10
View File
@@ -31,6 +31,8 @@ import (
const ( const (
defaultTrackingDays = 7 defaultTrackingDays = 7
maxTrackingDays = 30 maxTrackingDays = 30
defaultMonitoringListPageSize = 10
maxMonitoringListPageSize = 100
monitoringCollectorType = "desktop" monitoringCollectorType = "desktop"
monitoringOnlineDuration = 20 * time.Minute monitoringOnlineDuration = 20 * time.Minute
monitoringCollectNowQuestionLimit = 5 monitoringCollectNowQuestionLimit = 5
@@ -100,14 +102,29 @@ type MonitoringDashboardCompositeResponse struct {
CitationWindow MonitoringDashboardWindow `json:"citation_window"` CitationWindow MonitoringDashboardWindow `json:"citation_window"`
PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"` PlatformBreakdown []MonitoringPlatformDaily `json:"platform_breakdown"`
HotQuestions []MonitoringHotQuestion `json:"hot_questions"` HotQuestions []MonitoringHotQuestion `json:"hot_questions"`
HotQuestionsPage MonitoringPage `json:"hot_questions_page"`
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"` CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
CitedArticles []MonitoringCitedArticle `json:"cited_articles"` CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
CitedArticlesPage MonitoringPage `json:"cited_articles_page"`
} }
type MonitoringCitationSummaryResponse struct { type MonitoringCitationSummaryResponse struct {
CitationWindow MonitoringDashboardWindow `json:"citation_window"` CitationWindow MonitoringDashboardWindow `json:"citation_window"`
CitationRanking []MonitoringCitationRanking `json:"citation_ranking"` CitationRanking []MonitoringCitationRanking `json:"citation_ranking"`
CitedArticles []MonitoringCitedArticle `json:"cited_articles"` CitedArticles []MonitoringCitedArticle `json:"cited_articles"`
CitedArticlesPage MonitoringPage `json:"cited_articles_page"`
}
type MonitoringPage struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
type MonitoringPageRequest struct {
Page int
PageSize int
Query string
} }
type MonitoringDashboardWindow struct { type MonitoringDashboardWindow struct {
@@ -360,6 +377,7 @@ func (s *MonitoringService) DashboardComposite(
days int, days int,
businessDate string, businessDate string,
aiPlatformID *string, aiPlatformID *string,
hotQuestionPage MonitoringPageRequest,
) (*MonitoringDashboardCompositeResponse, error) { ) (*MonitoringDashboardCompositeResponse, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx) workspaceID := auth.CurrentWorkspaceID(ctx)
@@ -421,7 +439,17 @@ func (s *MonitoringService) DashboardComposite(
return nil, err return nil, err
} }
hotQuestions, err := s.loadHotQuestions(ctx, actor.TenantID, brand.ID, configuredQuestions, endDate, selectedPlatformID, derivedMetrics) hotQuestions, hotQuestionsPage, err := s.loadHotQuestionsPage(
ctx,
actor.TenantID,
brand.ID,
keywordID,
questionID,
endDate,
selectedPlatformID,
derivedMetrics,
hotQuestionPage,
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -436,8 +464,10 @@ func (s *MonitoringService) DashboardComposite(
}, },
PlatformBreakdown: platformBreakdown, PlatformBreakdown: platformBreakdown,
HotQuestions: hotQuestions, HotQuestions: hotQuestions,
HotQuestionsPage: hotQuestionsPage,
CitationRanking: []MonitoringCitationRanking{}, CitationRanking: []MonitoringCitationRanking{},
CitedArticles: []MonitoringCitedArticle{}, CitedArticles: []MonitoringCitedArticle{},
CitedArticlesPage: emptyMonitoringPage(normalizeMonitoringPageRequest(MonitoringPageRequest{}, defaultMonitoringListPageSize)),
}, nil }, nil
} }
@@ -449,6 +479,7 @@ func (s *MonitoringService) CitationSummary(
questionID *int64, questionID *int64,
businessDate string, businessDate string,
aiPlatformID *string, aiPlatformID *string,
citedArticlePage MonitoringPageRequest,
) (*MonitoringCitationSummaryResponse, error) { ) (*MonitoringCitationSummaryResponse, error) {
actor := auth.MustActor(ctx) actor := auth.MustActor(ctx)
workspaceID := auth.CurrentWorkspaceID(ctx) workspaceID := auth.CurrentWorkspaceID(ctx)
@@ -497,7 +528,7 @@ func (s *MonitoringService) CitationSummary(
return nil, err return nil, err
} }
citedArticles, err := s.loadCitedArticles(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID) citedArticles, citedArticlesPage, err := s.loadCitedArticlesPage(ctx, actor.TenantID, brandID, questionIDs, startDate, endDate, selectedPlatformID, citedArticlePage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -508,8 +539,9 @@ func (s *MonitoringService) CitationSummary(
From: startDate.Format("2006-01-02"), From: startDate.Format("2006-01-02"),
To: endDate.Format("2006-01-02"), To: endDate.Format("2006-01-02"),
}, },
CitationRanking: citationRanking, CitationRanking: citationRanking,
CitedArticles: citedArticles, CitedArticles: citedArticles,
CitedArticlesPage: citedArticlesPage,
}, nil }, nil
} }
@@ -1525,6 +1557,43 @@ func filterConfiguredQuestionsByQuestionID(questions []monitoringConfiguredQuest
return nil, response.ErrBadRequest(40041, "invalid_question", "question does not belong to the selected brand or question set") return nil, response.ErrBadRequest(40041, "invalid_question", "question does not belong to the selected brand or question set")
} }
func normalizeMonitoringPageRequest(req MonitoringPageRequest, defaultPageSize int) MonitoringPageRequest {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = defaultPageSize
}
if req.PageSize > maxMonitoringListPageSize {
req.PageSize = maxMonitoringListPageSize
}
req.Query = strings.TrimSpace(req.Query)
return req
}
func emptyMonitoringPage(req MonitoringPageRequest) MonitoringPage {
req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize)
return MonitoringPage{
Page: req.Page,
PageSize: req.PageSize,
Total: 0,
}
}
func monitoringPageFromRequest(req MonitoringPageRequest, total int64) MonitoringPage {
req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize)
return MonitoringPage{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
}
}
func monitoringPageOffset(req MonitoringPageRequest) int {
req = normalizeMonitoringPageRequest(req, defaultMonitoringListPageSize)
return (req.Page - 1) * req.PageSize
}
func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error { func (s *MonitoringService) syncMonitoringQuestionSnapshots(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, questions []monitoringConfiguredQuestion, pruneMissing bool) error {
if pruneMissing { if pruneMissing {
if len(questions) == 0 { if len(questions) == 0 {
@@ -3291,6 +3360,91 @@ func (s *MonitoringService) loadHotQuestions(
return items, nil return items, nil
} }
func (s *MonitoringService) loadHotQuestionsPage(
ctx context.Context,
tenantID, brandID int64,
keywordID *int64,
questionID *int64,
businessDate time.Time,
aiPlatformID *string,
derived monitoringDerivedMetrics,
pageReq MonitoringPageRequest,
) ([]MonitoringHotQuestion, MonitoringPage, error) {
pageReq = normalizeMonitoringPageRequest(pageReq, defaultMonitoringListPageSize)
var total int64
if err := s.businessPool.QueryRow(ctx, `
SELECT COUNT(*)
FROM brand_questions q
JOIN brand_keywords k
ON k.id = q.keyword_id
AND k.tenant_id = q.tenant_id
AND k.brand_id = q.brand_id
AND k.deleted_at IS NULL
AND k.status = 'active'
WHERE q.tenant_id = $1
AND q.brand_id = $2
AND q.deleted_at IS NULL
AND q.status = 'active'
AND ($3::bigint IS NULL OR q.keyword_id = $3)
AND ($4::bigint IS NULL OR q.id = $4)
AND ($5::text = '' OR q.question_text ILIKE '%' || $5 || '%')
`, tenantID, brandID, nullableInt64(keywordID), nullableInt64(questionID), pageReq.Query).Scan(&total); err != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count hot questions")
}
page := monitoringPageFromRequest(pageReq, total)
if total == 0 {
return []MonitoringHotQuestion{}, page, nil
}
rows, err := s.businessPool.Query(ctx, `
SELECT q.id, q.keyword_id, q.question_text
FROM brand_questions q
JOIN brand_keywords k
ON k.id = q.keyword_id
AND k.tenant_id = q.tenant_id
AND k.brand_id = q.brand_id
AND k.deleted_at IS NULL
AND k.status = 'active'
WHERE q.tenant_id = $1
AND q.brand_id = $2
AND q.deleted_at IS NULL
AND q.status = 'active'
AND ($3::bigint IS NULL OR q.keyword_id = $3)
AND ($4::bigint IS NULL OR q.id = $4)
AND ($5::text = '' OR q.question_text ILIKE '%' || $5 || '%')
ORDER BY q.keyword_id ASC, q.id ASC
LIMIT $6 OFFSET $7
`, tenantID, brandID, nullableInt64(keywordID), nullableInt64(questionID), pageReq.Query, pageReq.PageSize, monitoringPageOffset(pageReq))
if err != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to load hot questions")
}
defer rows.Close()
questions := make([]monitoringConfiguredQuestion, 0, pageReq.PageSize)
for rows.Next() {
var item monitoringConfiguredQuestion
if scanErr := rows.Scan(&item.ID, &item.KeywordID, &item.QuestionText); scanErr != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to parse hot questions")
}
item.QuestionHash = seededQuestionHash(item.QuestionText)
questions = append(questions, item)
}
if err := rows.Err(); err != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to iterate hot questions")
}
if len(questions) == 0 {
return []MonitoringHotQuestion{}, page, nil
}
items, err := s.loadHotQuestions(ctx, tenantID, brandID, questions, businessDate, aiPlatformID, derived)
if err != nil {
return nil, MonitoringPage{}, err
}
return items, page, nil
}
func (s *MonitoringService) loadCitationRanking( func (s *MonitoringService) loadCitationRanking(
ctx context.Context, ctx context.Context,
tenantID, brandID int64, tenantID, brandID int64,
@@ -3552,13 +3706,128 @@ func (s *MonitoringService) loadCitedArticles(
} }
return items[left].ArticleID < items[right].ArticleID return items[left].ArticleID < items[right].ArticleID
}) })
if len(items) > 10 {
items = items[:10]
}
return items, nil return items, nil
} }
func (s *MonitoringService) loadCitedArticlesPage(
ctx context.Context,
tenantID, brandID int64,
questionIDs []int64,
startDate, endDate time.Time,
aiPlatformID *string,
pageReq MonitoringPageRequest,
) ([]MonitoringCitedArticle, MonitoringPage, error) {
pageReq = normalizeMonitoringPageRequest(pageReq, defaultMonitoringListPageSize)
if questionIDs != nil && len(questionIDs) == 0 {
return []MonitoringCitedArticle{}, emptyMonitoringPage(pageReq), nil
}
platformQueryIDs := monitoringPlatformQueryIDs(aiPlatformID)
startDateText := startDate.Format("2006-01-02")
endDateText := endDate.Format("2006-01-02")
var totalSampleCount int64
if err := s.monitoringPool.QueryRow(ctx, `
SELECT COUNT(*)
FROM question_monitor_runs r
WHERE r.tenant_id = $1
AND ($2::bigint = 0 OR r.brand_id = $2)
AND r.collector_type = $3
AND r.status = 'succeeded'
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))
`, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)).Scan(&totalSampleCount); err != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count cited article denominator")
}
type citedArticleStats struct {
Total int64
TotalCitationCount int64
}
var stats citedArticleStats
if err := s.monitoringPool.QueryRow(ctx, `
WITH article_counts AS (
SELECT alias.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
ON alias.tenant_id = cf.tenant_id
AND alias.normalized_url = cf.normalized_url
AND alias.confidence = 'high'
WHERE cf.tenant_id = $1
AND ($2::bigint = 0 OR cf.brand_id = $2)
AND r.ai_platform_id <> 'qwen'
AND r.collector_type = $3
AND r.status = 'succeeded'
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
)
SELECT COUNT(*)::bigint, COALESCE(SUM(citation_count), 0)::bigint
FROM article_counts
`, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs)).Scan(&stats.Total, &stats.TotalCitationCount); err != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to count cited articles")
}
page := monitoringPageFromRequest(pageReq, stats.Total)
if stats.Total == 0 {
return []MonitoringCitedArticle{}, page, nil
}
rows, err := s.monitoringPool.Query(ctx, `
WITH article_counts AS (
SELECT
alias.article_id,
COALESCE(MAX(alias.article_title_snapshot), '未命名文章') 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
ON alias.tenant_id = cf.tenant_id
AND alias.normalized_url = cf.normalized_url
AND alias.confidence = 'high'
WHERE cf.tenant_id = $1
AND ($2::bigint = 0 OR cf.brand_id = $2)
AND r.ai_platform_id <> 'qwen'
AND r.collector_type = $3
AND r.status = 'succeeded'
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
)
SELECT article_id, article_title, publish_platform, citation_count
FROM article_counts
ORDER BY citation_count DESC, article_id ASC
LIMIT $8 OFFSET $9
`, tenantID, brandID, monitoringCollectorType, startDateText, endDateText, nullableInt64Array(questionIDs), nullableStringArray(platformQueryIDs), pageReq.PageSize, monitoringPageOffset(pageReq))
if err != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "query_failed", "failed to load cited articles")
}
defer rows.Close()
items := make([]MonitoringCitedArticle, 0, pageReq.PageSize)
for rows.Next() {
var item MonitoringCitedArticle
if scanErr := rows.Scan(&item.ArticleID, &item.ArticleTitle, &item.PublishPlatform, &item.CitationCount); scanErr != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to parse cited articles")
}
item.CitationRate = divideAsPointer(item.CitationCount, totalSampleCount)
item.SourceShare = divideAsPointer(item.CitationCount, stats.TotalCitationCount)
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, MonitoringPage{}, response.ErrInternal(50041, "scan_failed", "failed to iterate cited articles")
}
return items, page, nil
}
func (s *MonitoringService) loadQuestionText(ctx context.Context, tenantID, brandID, questionID int64) (string, error) { func (s *MonitoringService) loadQuestionText(ctx context.Context, tenantID, brandID, questionID int64) (string, error) {
var text string var text string
if err := s.businessPool.QueryRow(ctx, ` if err := s.businessPool.QueryRow(ctx, `
@@ -57,8 +57,13 @@ func (h *MonitoringHandler) DashboardComposite(c *gin.Context) {
} }
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id")) aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
hotQuestionPage, err := parseMonitoringPageRequest(c, "hot_questions", true)
if err != nil {
response.Error(c, err)
return
}
data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, questionID, days, c.Query("business_date"), aiPlatformID) data, svcErr := h.svc.DashboardComposite(c.Request.Context(), brandID, keywordID, questionID, days, c.Query("business_date"), aiPlatformID, hotQuestionPage)
if svcErr != nil { if svcErr != nil {
response.Error(c, svcErr) response.Error(c, svcErr)
return return
@@ -92,8 +97,13 @@ func (h *MonitoringHandler) CitationSummary(c *gin.Context) {
} }
aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id")) aiPlatformID := parseOptionalStringPointer(c.Query("ai_platform_id"))
citedArticlePage, err := parseMonitoringPageRequest(c, "cited_articles", false)
if err != nil {
response.Error(c, err)
return
}
data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, questionID, c.Query("business_date"), aiPlatformID) data, svcErr := h.svc.CitationSummary(c.Request.Context(), days, brandID, keywordID, questionID, c.Query("business_date"), aiPlatformID, citedArticlePage)
if svcErr != nil { if svcErr != nil {
response.Error(c, svcErr) response.Error(c, svcErr)
return return
@@ -201,3 +211,25 @@ func parseOptionalStringPointer(raw string) *string {
value := trimmed value := trimmed
return &value return &value
} }
func parseMonitoringPageRequest(c *gin.Context, prefix string, includeQuery bool) (app.MonitoringPageRequest, error) {
req := app.MonitoringPageRequest{}
if raw := c.Query(prefix + "_page"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return req, response.ErrBadRequest(40031, "invalid_page", prefix+"_page must be a positive number")
}
req.Page = value
}
if raw := c.Query(prefix + "_page_size"); raw != "" {
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return req, response.ErrBadRequest(40031, "invalid_page_size", prefix+"_page_size must be a positive number")
}
req.PageSize = value
}
if includeQuery {
req.Query = strings.TrimSpace(c.Query(prefix + "_q"))
}
return req, nil
}