feat: paginate tracking lists server-side
This commit is contained in:
@@ -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: '该平台暂无引用分析',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user