feat(monitoring): attribute SaaS citations by registrable domain
Persist host/registrable_domain/site_key on monitoring article URL aliases, populate them on desktop-publish completion, and use them in the citation pipeline to count how often SaaS-published content appears as a citation source. Expose a /citation-summary endpoint with a 7/30-day window switch and surface the new source-share metrics in the tracking dashboard. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,9 @@ type CitationRankingRow = {
|
||||
platform_name: string;
|
||||
cited_answer_count: number | null;
|
||||
cited_article_count: number | null;
|
||||
citation_source_count: number | null;
|
||||
saas_source_count: number | null;
|
||||
saas_source_rate: number | null;
|
||||
citation_rate: number | null;
|
||||
share: number;
|
||||
color: string;
|
||||
@@ -95,9 +98,11 @@ const selectedBrandId = useStorage<number | null>("tracking_selected_brand", nul
|
||||
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
|
||||
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
|
||||
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
|
||||
const selectedCitationWindowDays = useStorage<number>("tracking_selected_citation_window_days", 7);
|
||||
|
||||
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value);
|
||||
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
||||
selectedCitationWindowDays.value = normalizeCitationWindowDays(selectedCitationWindowDays.value);
|
||||
|
||||
const brandsQuery = useQuery({
|
||||
queryKey: ["tracking", "brands"],
|
||||
@@ -188,12 +193,14 @@ watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessD
|
||||
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined;
|
||||
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id);
|
||||
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
|
||||
const hasLegacyCitationDaysQuery = normalizeQueryValue(route.query.citation_days) !== "";
|
||||
|
||||
if (
|
||||
currentBrandId === nextBrandId &&
|
||||
currentKeywordId === nextKeywordId &&
|
||||
currentPlatformId === (nextPlatformId ?? "all") &&
|
||||
currentBusinessDate === nextBusinessDate
|
||||
currentBusinessDate === nextBusinessDate &&
|
||||
!hasLegacyCitationDaysQuery
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -240,6 +247,18 @@ const dashboardQuery = useQuery({
|
||||
}),
|
||||
});
|
||||
|
||||
const citationSummaryQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
"tracking",
|
||||
"citation-summary",
|
||||
selectedCitationWindowDays.value,
|
||||
]),
|
||||
queryFn: () =>
|
||||
monitoringApi.citationSummary({
|
||||
days: selectedCitationWindowDays.value,
|
||||
}),
|
||||
});
|
||||
|
||||
const aiAccountsQuery = useQuery({
|
||||
queryKey: ["tracking", "ai-platform-accounts"],
|
||||
queryFn: () => tenantAccountsApi.list(),
|
||||
@@ -405,34 +424,37 @@ const selectedMetricCards = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
if (!selectedDateHasData.value) {
|
||||
return [];
|
||||
}
|
||||
const citationWindowOptions = computed(() => [
|
||||
{ label: t("tracking.citationWindow7"), value: 7 },
|
||||
{ label: t("tracking.citationWindow30"), value: 30 },
|
||||
]);
|
||||
|
||||
const breakdown = dashboardQuery.data.value?.platform_breakdown ?? [];
|
||||
const ranking = dashboardQuery.data.value?.citation_ranking ?? [];
|
||||
const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
const ranking = citationSummaryQuery.data.value?.citation_ranking ?? [];
|
||||
const order = new Map<string, number>();
|
||||
const rows = new Map<string, CitationRankingRow>();
|
||||
|
||||
breakdown.forEach((item, index) => {
|
||||
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||
aiPlatformCatalog.forEach((item, index) => {
|
||||
const platformId = normalizeMonitoringPlatformId(item.id) || item.id;
|
||||
order.set(platformId, index);
|
||||
rows.set(platformId, {
|
||||
ai_platform_id: platformId,
|
||||
platform_name: item.platform_name,
|
||||
platform_name: item.label,
|
||||
cited_answer_count: null,
|
||||
cited_article_count: null,
|
||||
citation_source_count: null,
|
||||
saas_source_count: null,
|
||||
saas_source_rate: null,
|
||||
citation_rate: null,
|
||||
share: 0,
|
||||
...platformAppearance(platformId, item.platform_name),
|
||||
...platformAppearance(platformId, item.label),
|
||||
});
|
||||
});
|
||||
|
||||
ranking.forEach((item, index) => {
|
||||
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||
if (!order.has(platformId)) {
|
||||
order.set(platformId, breakdown.length + index);
|
||||
order.set(platformId, aiPlatformCatalog.length + index);
|
||||
}
|
||||
|
||||
const existing = rows.get(platformId);
|
||||
@@ -441,6 +463,9 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
platform_name: item.platform_name || existing?.platform_name || platformId,
|
||||
cited_answer_count: (existing?.cited_answer_count ?? 0) + (item.cited_answer_count ?? 0),
|
||||
cited_article_count: (existing?.cited_article_count ?? 0) + (item.cited_article_count ?? 0),
|
||||
citation_source_count: (existing?.citation_source_count ?? 0) + (item.citation_source_count ?? 0),
|
||||
saas_source_count: (existing?.saas_source_count ?? 0) + (item.saas_source_count ?? 0),
|
||||
saas_source_rate: item.saas_source_rate ?? existing?.saas_source_rate ?? null,
|
||||
citation_rate: item.citation_rate ?? existing?.citation_rate ?? null,
|
||||
share: 0,
|
||||
...platformAppearance(platformId, item.platform_name || existing?.platform_name || platformId),
|
||||
@@ -448,16 +473,16 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
});
|
||||
|
||||
const list = Array.from(rows.values());
|
||||
const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.cited_answer_count ?? 0, 0), 0);
|
||||
const totalAppearances = list.reduce((sum, item) => sum + Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0), 0);
|
||||
const enriched = list.map((item) => ({
|
||||
...item,
|
||||
share: totalAppearances > 0 ? Math.max(item.cited_answer_count ?? 0, 0) / totalAppearances : 0,
|
||||
share: totalAppearances > 0 ? Math.max(item.saas_source_count ?? item.cited_answer_count ?? 0, 0) / totalAppearances : 0,
|
||||
}));
|
||||
const hasCitationData = enriched.some((item) => (item.cited_answer_count ?? 0) > 0);
|
||||
const hasCitationData = enriched.some((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
||||
|
||||
return enriched.sort((left, right) => {
|
||||
if (hasCitationData) {
|
||||
const countDiff = (right.cited_answer_count ?? 0) - (left.cited_answer_count ?? 0);
|
||||
const countDiff = (right.saas_source_count ?? right.cited_answer_count ?? 0) - (left.saas_source_count ?? left.cited_answer_count ?? 0);
|
||||
if (countDiff !== 0) {
|
||||
return countDiff;
|
||||
}
|
||||
@@ -467,14 +492,11 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
});
|
||||
|
||||
const visibleCitedArticles = computed(() => {
|
||||
if (!selectedDateHasData.value) {
|
||||
return [];
|
||||
}
|
||||
return dashboardQuery.data.value?.cited_articles ?? [];
|
||||
return citationSummaryQuery.data.value?.cited_articles ?? [];
|
||||
});
|
||||
|
||||
const citationChartStyle = computed(() => {
|
||||
const segments = citationRankingRows.value.filter((item) => (item.cited_answer_count ?? 0) > 0);
|
||||
const segments = citationRankingRows.value.filter((item) => (item.saas_source_count ?? item.cited_answer_count ?? 0) > 0);
|
||||
|
||||
if (!segments.length) {
|
||||
return {
|
||||
@@ -533,6 +555,11 @@ function formatPercent(value: number | null | undefined): string {
|
||||
return `${(value * 100).toFixed(value < 0.1 ? 1 : 0)}%`;
|
||||
}
|
||||
|
||||
function normalizeCitationWindowDays(value: unknown): number {
|
||||
const numeric = Number(value);
|
||||
return numeric === 30 ? 30 : 7;
|
||||
}
|
||||
|
||||
function accountAuthLabel(account: DesktopAccountInfo | null): string {
|
||||
if (!account) {
|
||||
return "未绑定";
|
||||
@@ -866,11 +893,27 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</a-card>
|
||||
|
||||
<div class="tracking-grid tracking-grid--middle">
|
||||
<a-card class="tracking-panel tracking-panel--citation" :loading="dashboardQuery.isLoading.value">
|
||||
<a-card class="tracking-panel tracking-panel--citation" :loading="citationSummaryQuery.isLoading.value">
|
||||
<div class="tracking-citation-section">
|
||||
<div class="tracking-citation-header">
|
||||
<h3 class="tracking-citation-title">{{ t("tracking.citationRankingTitle") }}</h3>
|
||||
<p class="tracking-citation-desc">{{ t("tracking.citationRankingHint") }}</p>
|
||||
<div>
|
||||
<h3 class="tracking-citation-title">{{ t("tracking.citationRankingTitle") }}</h3>
|
||||
<p class="tracking-citation-desc">{{ t("tracking.citationRankingHint") }}</p>
|
||||
</div>
|
||||
<a-radio-group
|
||||
v-model:value="selectedCitationWindowDays"
|
||||
class="citation-window-switch"
|
||||
size="small"
|
||||
button-style="solid"
|
||||
>
|
||||
<a-radio-button
|
||||
v-for="item in citationWindowOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="citation-ranking-layout">
|
||||
@@ -908,6 +951,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
<div class="citation-ranking-sheet__head">
|
||||
<span>{{ t("tracking.columns.platform") }}</span>
|
||||
<span>{{ t("tracking.columns.appearances") }}</span>
|
||||
<span>{{ t("tracking.columns.saasSourceRate") }}</span>
|
||||
<span>{{ t("tracking.columns.citationRate") }}</span>
|
||||
</div>
|
||||
|
||||
@@ -925,7 +969,11 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</span>
|
||||
<strong>{{ item.platform_name }}</strong>
|
||||
</div>
|
||||
<span class="citation-ranking-sheet__count">{{ formatCount(item.cited_answer_count) }} {{ item.cited_answer_count != null ? '条' : '' }}</span>
|
||||
<span class="citation-ranking-sheet__count">
|
||||
{{ formatCount(item.saas_source_count ?? item.cited_answer_count) }}
|
||||
{{ item.saas_source_count != null ? t("tracking.citations") : "" }}
|
||||
</span>
|
||||
<strong class="citation-ranking-sheet__rate">{{ formatPercent(item.saas_source_rate) }}</strong>
|
||||
<strong class="citation-ranking-sheet__rate">{{ formatPercent(item.citation_rate) }}</strong>
|
||||
</div>
|
||||
|
||||
@@ -966,9 +1014,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
|
||||
<div class="tracking-grid tracking-grid--bottom">
|
||||
|
||||
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
|
||||
<a-card class="tracking-panel" :loading="citationSummaryQuery.isLoading.value">
|
||||
<div class="tracking-panel__header">
|
||||
<h3 class="panel-title">{{ t("tracking.citedArticlesTitle") }}</h3>
|
||||
<span class="tracking-panel__helper">{{ t("tracking.citationWindowLabel", { days: selectedCitationWindowDays }) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="article-list">
|
||||
@@ -983,6 +1032,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</div>
|
||||
<div class="article-list__meta">
|
||||
<span>{{ item.citation_count }} {{ t("tracking.citations") }}</span>
|
||||
<span>{{ t("tracking.columns.share") }} {{ formatPercent(item.source_share) }}</span>
|
||||
<strong>{{ formatPercent(item.citation_rate) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1152,6 +1202,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
.tracking-citation-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
@@ -1177,6 +1231,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.citation-window-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.tracking-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1398,10 +1456,11 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
.citation-ranking-sheet__head,
|
||||
.citation-ranking-sheet__row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr) minmax(0, 0.8fr);
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.72fr) minmax(0, 0.72fr) minmax(0, 0.72fr);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 32px;
|
||||
min-width: 560px;
|
||||
}
|
||||
|
||||
.citation-ranking-sheet__head {
|
||||
@@ -1580,9 +1639,13 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.tracking-citation-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.citation-ranking-sheet__head,
|
||||
.citation-ranking-sheet__row {
|
||||
grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.7fr) minmax(0, 0.7fr);
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.7fr) minmax(0, 0.7fr) minmax(0, 0.7fr);
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user