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:
2026-04-30 17:12:09 +08:00
parent 6e6e19cccb
commit fb166fa9fe
15 changed files with 836 additions and 143 deletions
+5 -1
View File
@@ -432,7 +432,10 @@ const enUS = {
backToQuestions: "Back to questions",
citationRanking: "Citation Ranking",
citationRankingTitle: "Citation Ranking",
citationRankingHint: "Track how often content published from this system appears across model answers.",
citationRankingHint: "Attribute SaaS-published content by registrable domain across model citation sources.",
citationWindow7: "Last 7 days",
citationWindow30: "Last 30 days",
citationWindowLabel: "Last {days} days",
citedArticles: "Cited Articles",
citedArticlesTitle: "Cited Articles",
answerContent: "Answer Content",
@@ -492,6 +495,7 @@ const enUS = {
citedAnswers: "Cited Answers",
citedArticles: "Cited Articles",
citationRate: "Citation Rate",
saasSourceRate: "Source Share",
citations: "Citations",
sourcePlatform: "Source",
channelName: "Channel",
+5 -1
View File
@@ -433,7 +433,10 @@ const zhCN = {
backToQuestions: "返回问题列表",
citationRanking: "Citation Ranking",
citationRankingTitle: "引用排行",
citationRankingHint: "统计系统发布内容在各模型回答中出现次数与覆盖率。",
citationRankingHint: "按主体域名归因 SaaS 发文在模型引用来源中的出现次数与占比。",
citationWindow7: "近 7 天",
citationWindow30: "近 30 天",
citationWindowLabel: "近 {days} 天",
citedArticles: "Cited Articles",
citedArticlesTitle: "被引用文章",
answerContent: "答案内容",
@@ -493,6 +496,7 @@ const zhCN = {
citedAnswers: "引用回答数",
citedArticles: "引用文章数",
citationRate: "引用率",
saasSourceRate: "来源占比",
citations: "引用次数",
sourcePlatform: "来源平台",
channelName: "渠道名称",
+8
View File
@@ -57,6 +57,7 @@ import type {
LoginResponse,
MediaPlatform,
MonitoringCollectNowResponse,
MonitoringCitationSummaryResponse,
MonitoringDashboardCompositeResponse,
MonitoringQuestionDetailResponse,
PlatformAccount,
@@ -1075,6 +1076,13 @@ export const monitoringApi = {
params,
});
},
citationSummary(params: {
days?: number;
}) {
return apiClient.get<MonitoringCitationSummaryResponse>("/api/tenant/monitoring/citation-summary", {
params,
});
},
questionDetail(
brandId: number,
questionId: number,
+91 -28
View File
@@ -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;
}