refactor(monitoring): simplify brand dashboard and harden citation rendering
- drop brand_time_buckets and sparkline/coverage UI; dashboard now returns a single day - compute mention rate from total actual samples instead of averaging per-platform rates - canonicalize ai_platform_id across services and aggregate platform-overview snapshots - strip mojibake and non-answer noise from callback payloads and question detail answers - keep Kimi citations panel-only; still store raw search_results for audit - render linked [n] inline citations for qwen/yuanbao answers with scroll-to-source behavior - restrict立即采集 to today's business date with a clear i18n hint - refresh content-citations into an ant-design table with truncation and hover styling Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -348,6 +348,7 @@ const enUS = {
|
|||||||
collectNow: "Collect Now",
|
collectNow: "Collect Now",
|
||||||
collectBrandRequired: "Select a brand before collecting.",
|
collectBrandRequired: "Select a brand before collecting.",
|
||||||
collectKeywordRequired: "Select a keyword before collecting the current question set.",
|
collectKeywordRequired: "Select a keyword before collecting the current question set.",
|
||||||
|
collectTodayOnly: "Collect Now only supports today's data. Switch back to today to continue.",
|
||||||
collectClientChecking: "Checking whether the current account's desktop client is online.",
|
collectClientChecking: "Checking whether the current account's desktop client is online.",
|
||||||
collectClientOnlineRequired: "The current account's desktop client must be online before collecting.",
|
collectClientOnlineRequired: "The current account's desktop client must be online before collecting.",
|
||||||
pluginMode: "Plugin sampling",
|
pluginMode: "Plugin sampling",
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ const zhCN = {
|
|||||||
collectNow: "立即采集",
|
collectNow: "立即采集",
|
||||||
collectBrandRequired: "请先选择品牌,再发起采集",
|
collectBrandRequired: "请先选择品牌,再发起采集",
|
||||||
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
collectKeywordRequired: "请先选择关键词,再立即采集当前问题",
|
||||||
|
collectTodayOnly: "立即采集只支持今天的数据,请切回今天后再操作",
|
||||||
collectClientChecking: "正在检查当前账号的桌面客户端状态",
|
collectClientChecking: "正在检查当前账号的桌面客户端状态",
|
||||||
collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集",
|
collectClientOnlineRequired: "当前登录账号的桌面客户端未在线,暂时不能立即采集",
|
||||||
pluginMode: "插件采样",
|
pluginMode: "插件采样",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { aiPlatformCatalog } from "@geo/shared-types";
|
||||||
|
|
||||||
|
const monitoringPlatformIds = new Set(aiPlatformCatalog.map((item) => item.id));
|
||||||
|
|
||||||
|
export function normalizeMonitoringPlatformId(platformId?: string | null): string {
|
||||||
|
return String(platformId ?? "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMonitoringPlatformId(platformId?: string | null): boolean {
|
||||||
|
const normalized = normalizeMonitoringPlatformId(platformId);
|
||||||
|
return Boolean(normalized) && monitoringPlatformIds.has(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMonitoringPlatformFilter(value: unknown): string {
|
||||||
|
const normalized = normalizeMonitoringPlatformId(
|
||||||
|
String(Array.isArray(value) ? value[0] ?? "" : value ?? ""),
|
||||||
|
);
|
||||||
|
if (!normalized) {
|
||||||
|
return "all";
|
||||||
|
}
|
||||||
|
return monitoringPlatformIds.has(normalized) ? normalized : "all";
|
||||||
|
}
|
||||||
@@ -6,8 +6,9 @@ import type {
|
|||||||
MonitoringQuestionCitationAnalysisItem,
|
MonitoringQuestionCitationAnalysisItem,
|
||||||
MonitoringQuestionDetailCitation,
|
MonitoringQuestionDetailCitation,
|
||||||
MonitoringQuestionDetailPlatform,
|
MonitoringQuestionDetailPlatform,
|
||||||
|
MonitoringSourceItem,
|
||||||
} from "@geo/shared-types";
|
} from "@geo/shared-types";
|
||||||
import { computed, ref, watch } from "vue";
|
import { computed, nextTick, onBeforeUnmount, 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";
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ import MarkdownPreview from "@/components/MarkdownPreview.vue";
|
|||||||
import { monitoringApi } from "@/lib/api";
|
import { monitoringApi } from "@/lib/api";
|
||||||
import { formatDateTime } from "@/lib/display";
|
import { formatDateTime } from "@/lib/display";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
|
import { isMonitoringPlatformId, normalizeMonitoringPlatformId } from "@/lib/monitoring-platforms";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -28,7 +30,10 @@ const questionId = computed(() => parsePositiveNumber(route.params.questionId));
|
|||||||
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash));
|
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash));
|
||||||
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text));
|
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text));
|
||||||
const keywordId = computed(() => normalizeQueryValue(route.query.keyword_id));
|
const keywordId = computed(() => normalizeQueryValue(route.query.keyword_id));
|
||||||
const requestedPlatformId = computed(() => normalizeQueryValue(route.query.ai_platform_id));
|
const requestedPlatformId = computed(() => {
|
||||||
|
const platformId = normalizeMonitoringPlatformId(normalizeQueryValue(route.query.ai_platform_id));
|
||||||
|
return isMonitoringPlatformId(platformId) ? platformId : "";
|
||||||
|
});
|
||||||
const businessDate = computed(() => normalizeTrackingBusinessDate(route.query.business_date) || trackingToday);
|
const businessDate = computed(() => normalizeTrackingBusinessDate(route.query.business_date) || trackingToday);
|
||||||
const dateFrom = computed(() => {
|
const dateFrom = computed(() => {
|
||||||
return normalizeQueryValue(route.query.date_from) || businessDate.value;
|
return normalizeQueryValue(route.query.date_from) || businessDate.value;
|
||||||
@@ -59,9 +64,15 @@ const detailQuery = useQuery({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const activePlatformId = ref("");
|
const activePlatformId = ref("");
|
||||||
|
const detailPlatforms = computed<MonitoringQuestionDetailPlatform[]>(() => detailQuery.data.value?.platforms ?? []);
|
||||||
|
const detailCitationAnalysis = computed<MonitoringQuestionCitationAnalysisItem[]>(() => detailQuery.data.value?.citation_analysis ?? []);
|
||||||
|
const citationSourcesScrollRef = ref<HTMLElement | null>(null);
|
||||||
|
const highlightedCitationIndex = ref<number | null>(null);
|
||||||
|
|
||||||
|
let citationHighlightTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => detailQuery.data.value?.platforms,
|
detailPlatforms,
|
||||||
(platforms) => {
|
(platforms) => {
|
||||||
if (!platforms?.length) {
|
if (!platforms?.length) {
|
||||||
activePlatformId.value = "";
|
activePlatformId.value = "";
|
||||||
@@ -85,12 +96,10 @@ const pageTitle = computed(() => {
|
|||||||
return detailQuery.data.value?.question_text ?? questionTitleFallback.value ?? t("tracking.questionDetailFallback");
|
return detailQuery.data.value?.question_text ?? questionTitleFallback.value ?? t("tracking.questionDetailFallback");
|
||||||
});
|
});
|
||||||
|
|
||||||
const activePlatform = computed<MonitoringQuestionDetailPlatform | null>(() => {
|
const activePlatform = computed<MonitoringQuestionDetailPlatform | null>(() => detailPlatforms.value.find((item) => item.ai_platform_id === activePlatformId.value) ?? null);
|
||||||
return detailQuery.data.value?.platforms.find((item) => item.ai_platform_id === activePlatformId.value) ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeCitationAnalysis = computed<MonitoringQuestionCitationAnalysisItem[]>(() => {
|
const activeCitationAnalysis = computed<MonitoringQuestionCitationAnalysisItem[]>(() => {
|
||||||
const items = detailQuery.data.value?.citation_analysis ?? [];
|
const items = detailCitationAnalysis.value;
|
||||||
if (!activePlatformId.value) {
|
if (!activePlatformId.value) {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@@ -98,7 +107,7 @@ const activeCitationAnalysis = computed<MonitoringQuestionCitationAnalysisItem[]
|
|||||||
});
|
});
|
||||||
|
|
||||||
const latestSampledAt = computed<string | null>(() => {
|
const latestSampledAt = computed<string | null>(() => {
|
||||||
const platforms = detailQuery.data.value?.platforms ?? [];
|
const platforms = detailPlatforms.value;
|
||||||
const sampledAtValues = platforms
|
const sampledAtValues = platforms
|
||||||
.map((item) => item.sampled_at)
|
.map((item) => item.sampled_at)
|
||||||
.filter((value): value is string => Boolean(value));
|
.filter((value): value is string => Boolean(value));
|
||||||
@@ -129,8 +138,8 @@ type TrackingAnalyzedContentCitation = {
|
|||||||
citedURL: string | null;
|
citedURL: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizedAnswerText = computed<string | null>(() => {
|
const renderedAnswerContent = computed<string | null>(() => {
|
||||||
return stripAnswerCitationMarkers(activePlatform.value?.answer_text);
|
return formatAnswerContent(activePlatform.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
|
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
|
||||||
@@ -141,6 +150,14 @@ const shouldShowInlineContentCitations = computed(() => {
|
|||||||
return activeInlineContentCitations.value.length > 0;
|
return activeInlineContentCitations.value.length > 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(activePlatformId, () => {
|
||||||
|
clearCitationHighlight();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearCitationHighlight();
|
||||||
|
});
|
||||||
|
|
||||||
function goBack(): void {
|
function goBack(): void {
|
||||||
void router.push({
|
void router.push({
|
||||||
name: "tracking",
|
name: "tracking",
|
||||||
@@ -183,24 +200,198 @@ function resolveCitationTitle(citation: MonitoringQuestionDetailCitation): strin
|
|||||||
return citation.cited_title || citation.article_title || citation.cited_url;
|
return citation.cited_title || citation.article_title || citation.cited_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLinkedInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||||||
|
return platformId === "qwen" || platformId === "yuanbao";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKimiInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||||||
|
return platformId === "kimi";
|
||||||
|
}
|
||||||
|
|
||||||
function createQwenSourceGroupPattern(): RegExp {
|
function createQwenSourceGroupPattern(): RegExp {
|
||||||
return /\[\[source_group_web_(\d+)\]\]/g;
|
return /\[\[source_group_web_(\d+)\]\]/g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripAnswerCitationMarkers(answerText: string | null | undefined): string | null {
|
function createYuanbaoCitationPattern(): RegExp {
|
||||||
|
return /\[citation:\s*(\d+)\]/gi;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createYuanbaoCitationStripPattern(): RegExp {
|
||||||
|
return /\[\s*citation\s*:[^\[\]]*?\]/gi;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createQwenSourceGroupStripPattern(): RegExp {
|
||||||
|
return /\[\[\s*source_group_web_[^\[\]]*?\]\]/gi;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMarkdownLinkPattern(): RegExp {
|
||||||
|
return /\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectInlineCitationIndexes(answerText: string): number[] {
|
||||||
|
const matches = [
|
||||||
|
...answerText.matchAll(createQwenSourceGroupPattern()),
|
||||||
|
...answerText.matchAll(createYuanbaoCitationPattern()),
|
||||||
|
];
|
||||||
|
|
||||||
|
return matches
|
||||||
|
.map((match) => Number(match[1]))
|
||||||
|
.filter((value) => Number.isFinite(value) && value > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeInlineCitationUrl(value: string | null | undefined): string {
|
||||||
|
const normalized = String(value ?? "").trim();
|
||||||
|
if (!normalized) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(normalized);
|
||||||
|
url.hash = "";
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveCitationChannelName(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname || "--";
|
||||||
|
} catch {
|
||||||
|
return "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectKimiInlineCitationOccurrences(answerText: string): Array<{
|
||||||
|
url: string;
|
||||||
|
label: string | null;
|
||||||
|
citationCount: number;
|
||||||
|
}> {
|
||||||
|
const counts = new Map<string, { label: string | null; citationCount: number }>();
|
||||||
|
const pattern = createMarkdownLinkPattern();
|
||||||
|
|
||||||
|
for (const match of answerText.matchAll(pattern)) {
|
||||||
|
const matchIndex = match.index ?? 0;
|
||||||
|
if (matchIndex > 0 && answerText[matchIndex - 1] === "!") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = String(match[1] ?? "").trim() || null;
|
||||||
|
const url = normalizeInlineCitationUrl(match[2]);
|
||||||
|
if (!url) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = counts.get(url);
|
||||||
|
if (existing) {
|
||||||
|
counts.set(url, {
|
||||||
|
label: existing.label ?? label,
|
||||||
|
citationCount: existing.citationCount + 1,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
counts.set(url, {
|
||||||
|
label,
|
||||||
|
citationCount: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(counts.entries()).map(([url, value]) => ({
|
||||||
|
url,
|
||||||
|
label: value.label,
|
||||||
|
citationCount: value.citationCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInlineCitationSourceLookup(platform: MonitoringQuestionDetailPlatform | null): Map<string, MonitoringSourceItem> {
|
||||||
|
const lookup = new Map<string, MonitoringSourceItem>();
|
||||||
|
for (const item of platform?.inline_citations ?? []) {
|
||||||
|
const normalizedURL = normalizeInlineCitationUrl(item.normalized_url ?? item.url);
|
||||||
|
if (!normalizedURL || lookup.has(normalizedURL)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lookup.set(normalizedURL, item);
|
||||||
|
}
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countHanCharacters(value: string): number {
|
||||||
|
return value.match(/[\u3400-\u9fff]/g)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSuspiciousMojibakeCharacters(value: string): number {
|
||||||
|
return value.match(/[\u00c0-\u017f\u2000-\u20ff]/g)?.length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function looksLikeNonAnswerNoise(value: string): boolean {
|
||||||
|
const hanCount = countHanCharacters(value);
|
||||||
|
const suspiciousCount = countSuspiciousMojibakeCharacters(value);
|
||||||
|
if (hanCount === 0) {
|
||||||
|
return suspiciousCount >= 4;
|
||||||
|
}
|
||||||
|
return suspiciousCount >= 8 && suspiciousCount > hanCount * 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeAnswerBody(answerText: string | null | undefined): string | null {
|
||||||
const normalized = String(answerText ?? "").trim();
|
const normalized = String(answerText ?? "").trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleaned = normalized
|
const lines = normalized.split(/\r?\n+/).map((line) => line.trim()).filter(Boolean);
|
||||||
.replace(createQwenSourceGroupPattern(), "")
|
const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line));
|
||||||
|
const sanitized = (keptLines.length ? keptLines.join("\n") : normalized)
|
||||||
.replace(/[ \t]+([,.;:!?,。!?;:])/g, "$1")
|
.replace(/[ \t]+([,.;:!?,。!?;:])/g, "$1")
|
||||||
.replace(/[ \t]{2,}/g, " ")
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
return cleaned || null;
|
return sanitized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInlineCitationMarker(
|
||||||
|
sourceGroupIndex: number,
|
||||||
|
platform: MonitoringQuestionDetailPlatform | null,
|
||||||
|
): string {
|
||||||
|
if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex < 1) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCitation = Boolean(platform?.citations[sourceGroupIndex - 1]);
|
||||||
|
const className = hasCitation
|
||||||
|
? "tracking-inline-citation"
|
||||||
|
: "tracking-inline-citation tracking-inline-citation--muted";
|
||||||
|
|
||||||
|
if (!hasCitation) {
|
||||||
|
return `<span class="${className}">[${sourceGroupIndex}]</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<button type="button" class="${className}" data-citation-index="${sourceGroupIndex}">[${sourceGroupIndex}]</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAnswerContent(platform: MonitoringQuestionDetailPlatform | null): string | null {
|
||||||
|
const sanitized = sanitizeAnswerBody(platform?.answer_text);
|
||||||
|
if (!sanitized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const withInlineMarkers = isLinkedInlineCitationPlatform(platform?.ai_platform_id)
|
||||||
|
? sanitized
|
||||||
|
.replace(createQwenSourceGroupPattern(), (_, sourceGroupIndex) => {
|
||||||
|
return buildInlineCitationMarker(Number(sourceGroupIndex), platform);
|
||||||
|
})
|
||||||
|
.replace(createYuanbaoCitationPattern(), (_, sourceGroupIndex) => {
|
||||||
|
return buildInlineCitationMarker(Number(sourceGroupIndex), platform);
|
||||||
|
})
|
||||||
|
: sanitized;
|
||||||
|
|
||||||
|
return withInlineMarkers
|
||||||
|
.replace(createQwenSourceGroupStripPattern(), "")
|
||||||
|
.replace(createYuanbaoCitationStripPattern(), "")
|
||||||
|
.replace(/[ \t]{2,}/g, " ")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function analyzeInlineContentCitations(
|
function analyzeInlineContentCitations(
|
||||||
@@ -211,17 +402,47 @@ function analyzeInlineContentCitations(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const matches = Array.from(answerText.matchAll(createQwenSourceGroupPattern()));
|
if (isKimiInlineCitationPlatform(platform?.ai_platform_id)) {
|
||||||
if (!matches.length) {
|
const occurrences = collectKimiInlineCitationOccurrences(answerText);
|
||||||
|
if (!occurrences.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCitationCount = occurrences.reduce((sum, item) => sum + item.citationCount, 0);
|
||||||
|
if (!totalCitationCount) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const inlineCitationLookup = buildInlineCitationSourceLookup(platform);
|
||||||
|
return occurrences
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (right.citationCount !== left.citationCount) {
|
||||||
|
return right.citationCount - left.citationCount;
|
||||||
|
}
|
||||||
|
return left.url.localeCompare(right.url, "zh-CN");
|
||||||
|
})
|
||||||
|
.map((item) => {
|
||||||
|
const source = inlineCitationLookup.get(item.url);
|
||||||
|
const contentName = source?.title?.trim() || item.label || item.url;
|
||||||
|
const channelName = source?.site_name?.trim() || deriveCitationChannelName(item.url);
|
||||||
|
return {
|
||||||
|
key: `kimi-${item.url}`,
|
||||||
|
contentName,
|
||||||
|
channelName,
|
||||||
|
citationCount: item.citationCount,
|
||||||
|
citationRate: item.citationCount / totalCitationCount,
|
||||||
|
citedURL: item.url,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceGroupIndexes = collectInlineCitationIndexes(answerText);
|
||||||
|
if (!sourceGroupIndexes.length) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const citationCounts = new Map<number, number>();
|
const citationCounts = new Map<number, number>();
|
||||||
for (const match of matches) {
|
for (const sourceGroupIndex of sourceGroupIndexes) {
|
||||||
const sourceGroupIndex = Number(match[1]);
|
|
||||||
if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
citationCounts.set(sourceGroupIndex, (citationCounts.get(sourceGroupIndex) ?? 0) + 1);
|
citationCounts.set(sourceGroupIndex, (citationCounts.get(sourceGroupIndex) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,20 +458,81 @@ function analyzeInlineContentCitations(
|
|||||||
}
|
}
|
||||||
return left[0] - right[0];
|
return left[0] - right[0];
|
||||||
})
|
})
|
||||||
.map(([sourceGroupIndex, citationCount]) => {
|
.flatMap(([sourceGroupIndex, citationCount]) => {
|
||||||
const citation = platform?.citations[sourceGroupIndex - 1] ?? null;
|
const citation = platform?.citations[sourceGroupIndex - 1] ?? null;
|
||||||
|
const citedURL = citation?.cited_url?.trim() ?? "";
|
||||||
|
if (!citedURL) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return [{
|
||||||
key: `${sourceGroupIndex}-${citation?.cited_url ?? `source_group_web_${sourceGroupIndex}`}`,
|
key: `${sourceGroupIndex}-${citedURL}`,
|
||||||
contentName: citation ? resolveCitationTitle(citation) : `引用内容 #${sourceGroupIndex}`,
|
contentName: citation ? resolveCitationTitle(citation) : `引用内容 #${sourceGroupIndex}`,
|
||||||
channelName: citation?.site_name ?? "--",
|
channelName: citation?.site_name ?? "--",
|
||||||
citationCount,
|
citationCount,
|
||||||
citationRate: citationCount / totalCitationCount,
|
citationRate: citationCount / totalCitationCount,
|
||||||
citedURL: citation?.cited_url ?? null,
|
citedURL,
|
||||||
};
|
}];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearCitationHighlight(): void {
|
||||||
|
if (citationHighlightTimer !== null) {
|
||||||
|
clearTimeout(citationHighlightTimer);
|
||||||
|
citationHighlightTimer = null;
|
||||||
|
}
|
||||||
|
highlightedCitationIndex.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function focusCitationSource(sourceGroupIndex: number): Promise<void> {
|
||||||
|
if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
const citationCard = citationSourcesScrollRef.value?.querySelector<HTMLElement>(
|
||||||
|
`[data-citation-index="${sourceGroupIndex}"]`,
|
||||||
|
);
|
||||||
|
if (!citationCard) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightedCitationIndex.value = sourceGroupIndex;
|
||||||
|
citationCard.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
inline: "nearest",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (citationHighlightTimer !== null) {
|
||||||
|
clearTimeout(citationHighlightTimer);
|
||||||
|
}
|
||||||
|
citationHighlightTimer = setTimeout(() => {
|
||||||
|
highlightedCitationIndex.value = null;
|
||||||
|
citationHighlightTimer = null;
|
||||||
|
}, 2400);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAnswerContentClick(event: MouseEvent): void {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = target.closest("[data-citation-index]") as HTMLElement | null;
|
||||||
|
if (!trigger) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceGroupIndex = Number(trigger.dataset.citationIndex ?? "");
|
||||||
|
if (!Number.isFinite(sourceGroupIndex) || sourceGroupIndex < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
void focusCitationSource(sourceGroupIndex);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeQueryValue(value: unknown): string {
|
function normalizeQueryValue(value: unknown): string {
|
||||||
const raw = Array.isArray(value) ? value[0] : value;
|
const raw = Array.isArray(value) ? value[0] : value;
|
||||||
return String(raw ?? "").trim();
|
return String(raw ?? "").trim();
|
||||||
@@ -282,6 +564,20 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const citationAnalysisColumns = computed(() => [
|
||||||
|
{ title: t("tracking.columns.sourcePlatform"), key: "sourcePlatform", dataIndex: "site_name", width: "30%" },
|
||||||
|
{ title: t("tracking.columns.domain"), key: "domain", dataIndex: "site_domain", width: "30%", align: "center" as const },
|
||||||
|
{ title: t("tracking.columns.citations"), key: "citationCount", dataIndex: "citation_count", align: "center" as const, width: "20%" },
|
||||||
|
{ title: t("tracking.columns.share"), key: "citationRate", dataIndex: "citation_rate", align: "center" as const, width: "20%" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const contentCitationColumns = computed(() => [
|
||||||
|
{ title: t("tracking.columns.contentName"), key: "contentName", dataIndex: "contentName", width: "40%" },
|
||||||
|
{ title: t("tracking.columns.channelName"), key: "channelName", dataIndex: "channelName", width: "20%", align: "center" as const },
|
||||||
|
{ title: t("tracking.columns.citations"), key: "citationCount", dataIndex: "citationCount", align: "center" as const, width: "20%" },
|
||||||
|
{ title: t("tracking.columns.share"), key: "citationRate", dataIndex: "citationRate", align: "center" as const, width: "20%" },
|
||||||
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -320,7 +616,7 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
class="tracking-question-tabs"
|
class="tracking-question-tabs"
|
||||||
>
|
>
|
||||||
<a-tab-pane
|
<a-tab-pane
|
||||||
v-for="platform in detailQuery.data.value?.platforms ?? []"
|
v-for="platform in detailPlatforms"
|
||||||
:key="platform.ai_platform_id"
|
:key="platform.ai_platform_id"
|
||||||
>
|
>
|
||||||
<template #tab>
|
<template #tab>
|
||||||
@@ -351,8 +647,9 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
|
|
||||||
<div class="tracking-question-answer__content custom-scrollbar">
|
<div class="tracking-question-answer__content custom-scrollbar">
|
||||||
<MarkdownPreview
|
<MarkdownPreview
|
||||||
v-if="sanitizedAnswerText"
|
v-if="renderedAnswerContent"
|
||||||
:content="sanitizedAnswerText"
|
:content="renderedAnswerContent"
|
||||||
|
@click="handleAnswerContentClick"
|
||||||
/>
|
/>
|
||||||
<a-empty v-else :description="t('tracking.noAnswer')" />
|
<a-empty v-else :description="t('tracking.noAnswer')" />
|
||||||
</div>
|
</div>
|
||||||
@@ -372,17 +669,20 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tracking-question-card__scroll-area custom-scrollbar">
|
<div ref="citationSourcesScrollRef" class="tracking-question-card__scroll-area custom-scrollbar">
|
||||||
<div v-if="activePlatform?.citations?.length" class="tracking-question-source-list">
|
<div v-if="activePlatform?.citations?.length" class="tracking-question-source-list">
|
||||||
<a
|
<a
|
||||||
v-for="citation in activePlatform.citations"
|
v-for="(citation, index) in activePlatform.citations"
|
||||||
:key="citation.cited_url"
|
:key="citation.cited_url"
|
||||||
:href="citation.cited_url"
|
:href="citation.cited_url"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
class="tracking-question-source-card"
|
class="tracking-question-source-card"
|
||||||
|
:class="{ 'is-linked': highlightedCitationIndex === index + 1 }"
|
||||||
|
:data-citation-index="index + 1"
|
||||||
>
|
>
|
||||||
<div class="tracking-question-source-card__headline">
|
<div class="tracking-question-source-card__headline">
|
||||||
|
<span class="tracking-question-source-card__index">[{{ index + 1 }}]</span>
|
||||||
<strong>{{ citation.site_name }}</strong>
|
<strong>{{ citation.site_name }}</strong>
|
||||||
<span class="tracking-question-source-card__divider" v-if="resolveCitationTitle(citation)">·</span>
|
<span class="tracking-question-source-card__divider" v-if="resolveCitationTitle(citation)">·</span>
|
||||||
<span class="tracking-question-source-card__title" :title="resolveCitationTitle(citation)">{{ resolveCitationTitle(citation) }}</span>
|
<span class="tracking-question-source-card__title" :title="resolveCitationTitle(citation)">{{ resolveCitationTitle(citation) }}</span>
|
||||||
@@ -397,6 +697,7 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Citation Analysis -->
|
||||||
<section
|
<section
|
||||||
:class="[
|
:class="[
|
||||||
'tracking-question-card',
|
'tracking-question-card',
|
||||||
@@ -410,35 +711,41 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tracking-question-card__scroll-area custom-scrollbar">
|
<div class="tracking-question-card__scroll-area custom-scrollbar" style="margin: 0; padding: 0;">
|
||||||
<div v-if="activeCitationAnalysis.length" class="tracking-question-table">
|
<a-table
|
||||||
<div class="tracking-question-table__head">
|
v-if="activeCitationAnalysis.length"
|
||||||
<span>{{ t("tracking.columns.sourcePlatform") }}</span>
|
row-key="site_key"
|
||||||
<span>{{ t("tracking.columns.domain") }}</span>
|
class="modern-table"
|
||||||
<span>{{ t("tracking.columns.citations") }}</span>
|
:columns="citationAnalysisColumns"
|
||||||
<span>{{ t("tracking.columns.share") }}</span>
|
:data-source="activeCitationAnalysis"
|
||||||
</div>
|
:pagination="false"
|
||||||
|
size="middle"
|
||||||
<div
|
>
|
||||||
v-for="item in activeCitationAnalysis"
|
<template #bodyCell="{ column, record }">
|
||||||
:key="`${item.ai_platform_id}-${item.site_key}`"
|
<template v-if="column.key === 'sourcePlatform'">
|
||||||
class="tracking-question-table__row"
|
<div class="cell-primary-stack">
|
||||||
>
|
<strong class="table-text" :title="record.site_name">{{ record.site_name }}</strong>
|
||||||
<span class="tracking-question-table__primary">
|
<small v-if="record.content_citation_count > 0" class="meta-subtext">
|
||||||
<strong>{{ item.site_name }}</strong>
|
{{ t("tracking.contentCitationMeta", { count: record.content_citation_count }) }}
|
||||||
<small v-if="item.content_citation_count > 0">
|
</small>
|
||||||
{{ t("tracking.contentCitationMeta", { count: item.content_citation_count }) }}
|
</div>
|
||||||
</small>
|
</template>
|
||||||
</span>
|
<template v-else-if="column.key === 'domain'">
|
||||||
<span class="tracking-question-table__secondary">{{ item.site_domain || item.site_key }}</span>
|
<span class="tracking-question-table__secondary">{{ record.site_domain || record.site_key }}</span>
|
||||||
<span>{{ item.citation_count }}</span>
|
</template>
|
||||||
<strong>{{ formatPercent(item.citation_rate) }}</strong>
|
<template v-else-if="column.key === 'citationCount'">
|
||||||
</div>
|
<span class="metric-value">{{ record.citation_count }}</span>
|
||||||
</div>
|
</template>
|
||||||
<a-empty v-else :description="t('tracking.noCitationAnalysis')" />
|
<template v-else-if="column.key === 'citationRate'">
|
||||||
|
<strong class="metric-highlight">{{ formatPercent(record.citation_rate) }}</strong>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
<a-empty v-else :description="t('tracking.noCitationAnalysis')" style="margin-top: 40px;" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Content Citations -->
|
||||||
<section v-if="shouldShowInlineContentCitations" class="tracking-question-card">
|
<section v-if="shouldShowInlineContentCitations" class="tracking-question-card">
|
||||||
<div class="tracking-question-card__header">
|
<div class="tracking-question-card__header">
|
||||||
<div class="tracking-question-card__section-title">
|
<div class="tracking-question-card__section-title">
|
||||||
@@ -447,41 +754,42 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tracking-question-card__scroll-area custom-scrollbar">
|
<div class="tracking-question-card__scroll-area custom-scrollbar" style="margin: 0; padding: 0;">
|
||||||
<div class="tracking-question-table">
|
<a-table
|
||||||
<div class="tracking-question-table__head">
|
row-key="key"
|
||||||
<span>{{ t("tracking.columns.contentName") }}</span>
|
class="modern-table"
|
||||||
<span>{{ t("tracking.columns.channelName") }}</span>
|
:columns="contentCitationColumns"
|
||||||
<span>{{ t("tracking.columns.citations") }}</span>
|
:data-source="activeInlineContentCitations"
|
||||||
<span>{{ t("tracking.columns.share") }}</span>
|
:pagination="false"
|
||||||
</div>
|
size="middle"
|
||||||
|
>
|
||||||
<div
|
<template #bodyCell="{ column, record }">
|
||||||
v-for="item in activeInlineContentCitations"
|
<template v-if="column.key === 'contentName'">
|
||||||
:key="item.key"
|
<div class="cell-primary-stack">
|
||||||
class="tracking-question-table__row"
|
<a
|
||||||
>
|
v-if="record.citedURL"
|
||||||
<div class="tracking-question-table__primary">
|
:href="record.citedURL"
|
||||||
<a
|
target="_blank"
|
||||||
v-if="item.citedURL"
|
rel="noreferrer"
|
||||||
:href="item.citedURL"
|
:title="record.contentName"
|
||||||
target="_blank"
|
class="table-link"
|
||||||
rel="noreferrer"
|
>
|
||||||
:title="item.contentName"
|
{{ record.contentName }}
|
||||||
>
|
</a>
|
||||||
{{ item.contentName }}
|
<strong v-else class="table-text" :title="record.contentName">{{ record.contentName }}</strong>
|
||||||
</a>
|
</div>
|
||||||
<strong v-else>{{ item.contentName }}</strong>
|
</template>
|
||||||
</div>
|
<template v-else-if="column.key === 'channelName'">
|
||||||
<span class="tracking-question-table__secondary">{{ item.channelName }}</span>
|
<span class="tracking-question-table__secondary" :title="record.channelName">{{ record.channelName }}</span>
|
||||||
<div class="tracking-question-table__metric">
|
</template>
|
||||||
<span>{{ item.citationCount }}</span>
|
<template v-else-if="column.key === 'citationCount'">
|
||||||
</div>
|
<span class="metric-value">{{ record.citationCount }}</span>
|
||||||
<div class="tracking-question-table__metric">
|
</template>
|
||||||
<strong>{{ formatPercent(item.citationRate) }}</strong>
|
<template v-else-if="column.key === 'citationRate'">
|
||||||
</div>
|
<strong class="metric-highlight">{{ formatPercent(record.citationRate) }}</strong>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</template>
|
||||||
|
</a-table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -755,6 +1063,12 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.05);
|
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking-question-source-card.is-linked {
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background: #eef6ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.tracking-question-source-card__headline {
|
.tracking-question-source-card__headline {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -762,6 +1076,21 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tracking-question-source-card__index {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e8f1ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tracking-question-source-card__headline strong {
|
.tracking-question-source-card__headline strong {
|
||||||
display: block;
|
display: block;
|
||||||
color: #2563eb;
|
color: #2563eb;
|
||||||
@@ -805,70 +1134,137 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-table {
|
.tracking-question-answer__content :deep(.tracking-inline-citation) {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-question-table__head,
|
|
||||||
.tracking-question-table__row {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr) 92px 92px;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 18px 0;
|
justify-content: center;
|
||||||
}
|
min-width: 28px;
|
||||||
|
height: 22px;
|
||||||
.tracking-question-table__head {
|
margin-left: 6px;
|
||||||
color: #9ca3af;
|
padding: 0 8px;
|
||||||
font-size: 13px;
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
border-bottom: 1px solid #edf2f7;
|
line-height: 1;
|
||||||
|
vertical-align: baseline;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-table__row {
|
.tracking-question-answer__content :deep(.tracking-inline-citation:hover) {
|
||||||
color: #4b5563;
|
border-color: #93c5fd;
|
||||||
border-bottom: 1px solid #f3f4f6;
|
background: #dbeafe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-table__row:last-child {
|
.tracking-question-answer__content :deep(.tracking-inline-citation--muted) {
|
||||||
border-bottom: 0;
|
cursor: default;
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-question-table__primary {
|
|
||||||
display: flex;
|
|
||||||
min-width: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-question-table__primary strong,
|
|
||||||
.tracking-question-table__primary a {
|
|
||||||
color: #111827;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-question-table__primary small {
|
|
||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern Enterprise Table Styles */
|
||||||
|
:deep(.modern-table) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.modern-table .ant-table-thead > tr > th) {
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
padding: 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.modern-table .ant-table-tbody > tr > td) {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.modern-table .ant-table-tbody > tr:last-child > td) {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.modern-table .ant-table-tbody > tr:hover > td) {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-primary-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-link:hover {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-text {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-table__secondary {
|
.tracking-question-table__secondary {
|
||||||
color: #94a3b8;
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-table__metric {
|
.meta-subtext {
|
||||||
display: flex;
|
color: #94a3b8;
|
||||||
justify-content: flex-end;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-table__metric strong,
|
.metric-value {
|
||||||
.tracking-question-table__row > strong {
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
color: #4b5563;
|
color: #475569;
|
||||||
font-size: 16px;
|
font-weight: 600;
|
||||||
justify-self: end;
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-highlight {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
color: #0ea5e9;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #f0f9ff;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e0f2fe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-card--debug {
|
.tracking-question-card--debug {
|
||||||
@@ -1030,8 +1426,7 @@ function parsePositiveNumber(value: unknown): number | null {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-question-source-card__meta,
|
.tracking-question-source-card__meta {
|
||||||
.tracking-question-table__metric {
|
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import type {
|
|||||||
Brand,
|
Brand,
|
||||||
DesktopAccountInfo,
|
DesktopAccountInfo,
|
||||||
MonitoringHotQuestion,
|
MonitoringHotQuestion,
|
||||||
MonitoringTimeBucket,
|
|
||||||
} from "@geo/shared-types";
|
} from "@geo/shared-types";
|
||||||
import { aiPlatformCatalog } from "@geo/shared-types";
|
import { aiPlatformCatalog } from "@geo/shared-types";
|
||||||
import { computed, watch } from "vue";
|
import { computed, watch } from "vue";
|
||||||
@@ -21,6 +20,7 @@ import PageHero from "@/components/PageHero.vue";
|
|||||||
import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
|
import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
|
||||||
import { formatDateTime } from "@/lib/display";
|
import { formatDateTime } from "@/lib/display";
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
|
import { isMonitoringPlatformId, normalizeMonitoringPlatformFilter, normalizeMonitoringPlatformId } from "@/lib/monitoring-platforms";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -76,10 +76,7 @@ const platformAppearanceMap: Record<string, { color: string; surface: string; gl
|
|||||||
doubao: { color: "#ff9348", surface: "#fff2e8", glyph: "豆" },
|
doubao: { color: "#ff9348", surface: "#fff2e8", glyph: "豆" },
|
||||||
deepseek: { color: "#5d72ff", surface: "#eef1ff", glyph: "D" },
|
deepseek: { color: "#5d72ff", surface: "#eef1ff", glyph: "D" },
|
||||||
qwen: { color: "#276ef1", surface: "#edf4ff", glyph: "通" },
|
qwen: { color: "#276ef1", surface: "#edf4ff", glyph: "通" },
|
||||||
tongyi: { color: "#6d5efc", surface: "#f1efff", glyph: "通" },
|
|
||||||
tongyiqianwen: { color: "#6d5efc", surface: "#f1efff", glyph: "通" },
|
|
||||||
wenxin: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
|
wenxin: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
|
||||||
wenxinyiyan: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
|
|
||||||
yuanbao: { color: "#19bf6b", surface: "#eafbf1", glyph: "元" },
|
yuanbao: { color: "#19bf6b", surface: "#eafbf1", glyph: "元" },
|
||||||
kimi: { color: "#111827", surface: "#f3f4f6", glyph: "K" },
|
kimi: { color: "#111827", surface: "#f3f4f6", glyph: "K" },
|
||||||
};
|
};
|
||||||
@@ -92,16 +89,12 @@ const trackingPlatformOptions = [
|
|||||||
})),
|
})),
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const trackingPlatformIds = new Set<string>(
|
|
||||||
aiPlatformCatalog.map((item) => item.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
|
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
|
||||||
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
|
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
|
||||||
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
|
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
|
||||||
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
|
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
|
||||||
|
|
||||||
selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value);
|
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value);
|
||||||
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
||||||
|
|
||||||
const brandsQuery = useQuery({
|
const brandsQuery = useQuery({
|
||||||
@@ -179,7 +172,7 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => route.query.ai_platform_id,
|
() => route.query.ai_platform_id,
|
||||||
(value) => {
|
(value) => {
|
||||||
selectedPlatformId.value = normalizeTrackingPlatformId(value);
|
selectedPlatformId.value = normalizeMonitoringPlatformFilter(value);
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
@@ -191,7 +184,7 @@ watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessD
|
|||||||
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday;
|
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday;
|
||||||
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined;
|
const currentBrandId = normalizeQueryValue(route.query.brand_id) || undefined;
|
||||||
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined;
|
const currentKeywordId = normalizeQueryValue(route.query.keyword_id) || undefined;
|
||||||
const currentPlatformId = normalizeTrackingPlatformId(route.query.ai_platform_id);
|
const currentPlatformId = normalizeMonitoringPlatformFilter(route.query.ai_platform_id);
|
||||||
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
|
const currentBusinessDate = normalizeTrackingBusinessDate(route.query.business_date) ?? trackingToday;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -297,6 +290,9 @@ const collectNowDisabledReason = computed(() => {
|
|||||||
if (!selectedKeywordId.value) {
|
if (!selectedKeywordId.value) {
|
||||||
return t("tracking.collectKeywordRequired");
|
return t("tracking.collectKeywordRequired");
|
||||||
}
|
}
|
||||||
|
if (selectedBusinessDate.value !== trackingToday) {
|
||||||
|
return t("tracking.collectTodayOnly");
|
||||||
|
}
|
||||||
if (dashboardQuery.isLoading.value && !dashboardQuery.data.value) {
|
if (dashboardQuery.isLoading.value && !dashboardQuery.data.value) {
|
||||||
return t("tracking.collectClientChecking");
|
return t("tracking.collectClientChecking");
|
||||||
}
|
}
|
||||||
@@ -317,16 +313,17 @@ const hotQuestions = computed(() => {
|
|||||||
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 ?? []) {
|
||||||
if (!trackingPlatformIds.has(account.platform) || account.deleted_at) {
|
const platformId = normalizeMonitoringPlatformId(account.platform);
|
||||||
|
if (!isMonitoringPlatformId(platformId) || account.deleted_at) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const list = accountGroups.get(account.platform) ?? [];
|
const list = accountGroups.get(platformId) ?? [];
|
||||||
list.push(account);
|
list.push(account);
|
||||||
accountGroups.set(account.platform, list);
|
accountGroups.set(platformId, list);
|
||||||
}
|
}
|
||||||
|
|
||||||
const breakdownMap = new Map(
|
const breakdownMap = new Map(
|
||||||
(dashboardQuery.data.value?.platform_breakdown ?? []).map((item) => [item.ai_platform_id, item]),
|
(dashboardQuery.data.value?.platform_breakdown ?? []).map((item) => [normalizeMonitoringPlatformId(item.ai_platform_id), item]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return aiPlatformCatalog.map((platform) => {
|
return aiPlatformCatalog.map((platform) => {
|
||||||
@@ -353,55 +350,34 @@ const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => {
|
|||||||
|
|
||||||
const selectedMetricCards = computed(() => {
|
const selectedMetricCards = computed(() => {
|
||||||
const overview = dashboardQuery.data.value?.overview;
|
const overview = dashboardQuery.data.value?.overview;
|
||||||
const buckets = dashboardQuery.data.value?.brand_time_buckets ?? [];
|
|
||||||
const points = selectedDateHasData.value ? buckets : [];
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: "mention",
|
key: "mention",
|
||||||
title: t("tracking.metrics.mentionRate"),
|
title: t("tracking.metrics.mentionRate"),
|
||||||
value: overview?.mention_rate ?? null,
|
value: overview?.mention_rate ?? null,
|
||||||
previous: overview?.prev_snapshot_mention_rate ?? null,
|
previous: overview?.prev_snapshot_mention_rate ?? null,
|
||||||
points: points.map((item) => item.mention_rate),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "top1",
|
key: "top1",
|
||||||
title: t("tracking.metrics.top1MentionRate"),
|
title: t("tracking.metrics.top1MentionRate"),
|
||||||
value: overview?.top1_mention_rate ?? null,
|
value: overview?.top1_mention_rate ?? null,
|
||||||
previous: overview?.prev_snapshot_top1_mention_rate ?? null,
|
previous: overview?.prev_snapshot_top1_mention_rate ?? null,
|
||||||
points: points.map((item) => item.top1_mention_rate),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "first",
|
key: "first",
|
||||||
title: t("tracking.metrics.firstRecommendRate"),
|
title: t("tracking.metrics.firstRecommendRate"),
|
||||||
value: overview?.first_recommend_rate ?? null,
|
value: overview?.first_recommend_rate ?? null,
|
||||||
previous: overview?.prev_snapshot_first_recommend_rate ?? null,
|
previous: overview?.prev_snapshot_first_recommend_rate ?? null,
|
||||||
points: points.map((item) => item.first_recommend_rate),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "positive",
|
key: "positive",
|
||||||
title: t("tracking.metrics.positiveMentionRate"),
|
title: t("tracking.metrics.positiveMentionRate"),
|
||||||
value: overview?.positive_mention_rate ?? null,
|
value: overview?.positive_mention_rate ?? null,
|
||||||
previous: overview?.prev_snapshot_positive_mention_rate ?? null,
|
previous: overview?.prev_snapshot_positive_mention_rate ?? null,
|
||||||
points: points.map((item) => item.positive_mention_rate),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
const coverageSummary = computed(() => {
|
|
||||||
const overview = dashboardQuery.data.value?.overview;
|
|
||||||
if (!overview) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
if (!selectedDateHasData.value) {
|
|
||||||
return `${selectedBusinessDate.value} 暂无抓取数据`;
|
|
||||||
}
|
|
||||||
return t("tracking.coverageSummary", {
|
|
||||||
desired: overview.desired_sample_count,
|
|
||||||
actual: overview.actual_sample_count,
|
|
||||||
coverage: formatPercent(overview.coverage_rate),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||||
if (!selectedDateHasData.value) {
|
if (!selectedDateHasData.value) {
|
||||||
return [];
|
return [];
|
||||||
@@ -413,32 +389,34 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
|||||||
const rows = new Map<string, CitationRankingRow>();
|
const rows = new Map<string, CitationRankingRow>();
|
||||||
|
|
||||||
breakdown.forEach((item, index) => {
|
breakdown.forEach((item, index) => {
|
||||||
order.set(item.ai_platform_id, index);
|
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||||
rows.set(item.ai_platform_id, {
|
order.set(platformId, index);
|
||||||
ai_platform_id: item.ai_platform_id,
|
rows.set(platformId, {
|
||||||
|
ai_platform_id: platformId,
|
||||||
platform_name: item.platform_name,
|
platform_name: item.platform_name,
|
||||||
cited_answer_count: null,
|
cited_answer_count: null,
|
||||||
cited_article_count: null,
|
cited_article_count: null,
|
||||||
citation_rate: null,
|
citation_rate: null,
|
||||||
share: 0,
|
share: 0,
|
||||||
...platformAppearance(item.ai_platform_id, item.platform_name),
|
...platformAppearance(platformId, item.platform_name),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ranking.forEach((item, index) => {
|
ranking.forEach((item, index) => {
|
||||||
if (!order.has(item.ai_platform_id)) {
|
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||||
order.set(item.ai_platform_id, breakdown.length + index);
|
if (!order.has(platformId)) {
|
||||||
|
order.set(platformId, breakdown.length + index);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = rows.get(item.ai_platform_id);
|
const existing = rows.get(platformId);
|
||||||
rows.set(item.ai_platform_id, {
|
rows.set(platformId, {
|
||||||
ai_platform_id: item.ai_platform_id,
|
ai_platform_id: platformId,
|
||||||
platform_name: item.platform_name || existing?.platform_name || item.ai_platform_id,
|
platform_name: item.platform_name || existing?.platform_name || platformId,
|
||||||
cited_answer_count: item.cited_answer_count,
|
cited_answer_count: (existing?.cited_answer_count ?? 0) + (item.cited_answer_count ?? 0),
|
||||||
cited_article_count: item.cited_article_count,
|
cited_article_count: (existing?.cited_article_count ?? 0) + (item.cited_article_count ?? 0),
|
||||||
citation_rate: item.citation_rate,
|
citation_rate: item.citation_rate ?? existing?.citation_rate ?? null,
|
||||||
share: 0,
|
share: 0,
|
||||||
...platformAppearance(item.ai_platform_id, item.platform_name || existing?.platform_name || item.ai_platform_id),
|
...platformAppearance(platformId, item.platform_name || existing?.platform_name || platformId),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -589,30 +567,6 @@ function formatDelta(current: number | null | undefined, previous: number | null
|
|||||||
return `${prefix}${(diff * 100).toFixed(1)}%`;
|
return `${prefix}${(diff * 100).toFixed(1)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function bucketPointClass(bucket: MonitoringTimeBucket, point: number | null): string {
|
|
||||||
if (point === null || point === undefined) {
|
|
||||||
return "metric-card__point metric-card__point--empty";
|
|
||||||
}
|
|
||||||
if (bucket.sample_status === "partial") {
|
|
||||||
return "metric-card__point metric-card__point--partial";
|
|
||||||
}
|
|
||||||
return "metric-card__point metric-card__point--solid";
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayDotClass(bucket: MonitoringTimeBucket): string {
|
|
||||||
if (bucket.sample_status === "sampled") {
|
|
||||||
return "tracking-day-dot tracking-day-dot--sampled";
|
|
||||||
}
|
|
||||||
if (bucket.sample_status === "partial") {
|
|
||||||
return "tracking-day-dot tracking-day-dot--partial";
|
|
||||||
}
|
|
||||||
return "tracking-day-dot";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTriggerSource(value: string): string {
|
|
||||||
return value === "manual" ? t("tracking.manual") : t("tracking.automatic");
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusTagColor(status: string): string {
|
function statusTagColor(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "sampled":
|
case "sampled":
|
||||||
@@ -633,7 +587,7 @@ function statusLabel(status: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function platformAppearance(aiPlatformId: string, platformName: string) {
|
function platformAppearance(aiPlatformId: string, platformName: string) {
|
||||||
const normalizedId = aiPlatformId.toLowerCase().replace(/[^a-z0-9]/g, "");
|
const normalizedId = normalizeMonitoringPlatformId(aiPlatformId) || aiPlatformId.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
const matched = platformAppearanceMap[normalizedId];
|
const matched = platformAppearanceMap[normalizedId];
|
||||||
|
|
||||||
if (matched) {
|
if (matched) {
|
||||||
@@ -661,14 +615,6 @@ function normalizeQueryValue(value: unknown): string {
|
|||||||
return String(Array.isArray(value) ? value[0] ?? "" : value ?? "").trim();
|
return String(Array.isArray(value) ? value[0] ?? "" : value ?? "").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTrackingPlatformId(value: unknown): string {
|
|
||||||
const normalized = normalizeQueryValue(value).toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
return "all";
|
|
||||||
}
|
|
||||||
return trackingPlatformIds.has(normalized) ? normalized : "all";
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTrackingBusinessDate(value: unknown): string | null {
|
function normalizeTrackingBusinessDate(value: unknown): string | null {
|
||||||
const normalized = normalizeQueryValue(value);
|
const normalized = normalizeQueryValue(value);
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -784,13 +730,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
{{ formatDelta(card.value, card.previous) }}
|
{{ formatDelta(card.value, card.previous) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card__sparkline">
|
|
||||||
<span
|
|
||||||
v-for="(bucket, index) in dashboardQuery.data.value?.brand_time_buckets ?? []"
|
|
||||||
:key="`${card.key}-${bucket.date}`"
|
|
||||||
:class="bucketPointClass(bucket, card.points[index])"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
@@ -895,57 +834,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<div class="tracking-grid tracking-grid--middle">
|
<div class="tracking-grid tracking-grid--middle">
|
||||||
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
|
|
||||||
<div class="tracking-panel__header">
|
|
||||||
<h3 class="panel-title">{{ t("tracking.coverageTitle") }}</h3>
|
|
||||||
<span class="tracking-panel__helper">{{ coverageSummary }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-progress
|
|
||||||
:percent="Math.round((dashboardQuery.data.value?.overview.coverage_rate ?? 0) * 100)"
|
|
||||||
:show-info="false"
|
|
||||||
stroke-color="#f97316"
|
|
||||||
trail-color="#fff1e6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div class="tracking-day-row">
|
|
||||||
<a-tooltip
|
|
||||||
v-for="bucket in dashboardQuery.data.value?.brand_time_buckets ?? []"
|
|
||||||
:key="bucket.date"
|
|
||||||
:title="`${bucket.date} · ${statusLabel(bucket.sample_status)} · ${bucket.actual_sample_count}/${bucket.planned_sample_count}`"
|
|
||||||
>
|
|
||||||
<span :class="dayDotClass(bucket)" />
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
|
|
||||||
<div class="tracking-panel__header">
|
|
||||||
<h3 class="panel-title">{{ t("tracking.hotQuestionsTitle") }}</h3>
|
|
||||||
<span class="tracking-panel__helper">{{ t("tracking.hotQuestionsHint") }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="hotQuestions.length" class="question-list">
|
|
||||||
<button
|
|
||||||
v-for="question in hotQuestions"
|
|
||||||
:key="`${question.question_id}-${question.question_hash}`"
|
|
||||||
type="button"
|
|
||||||
class="question-list__item"
|
|
||||||
@click="openQuestion(question)"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<strong>{{ question.question_text }}</strong>
|
|
||||||
<p>{{ t("tracking.metrics.mentionRate") }} {{ formatPercent(question.mention_rate) }}</p>
|
|
||||||
</div>
|
|
||||||
<span class="question-list__time">{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-empty v-else :description="t('tracking.noQuestions')" />
|
|
||||||
</a-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tracking-grid tracking-grid--bottom">
|
|
||||||
<a-card class="tracking-panel tracking-panel--citation" :loading="dashboardQuery.isLoading.value">
|
<a-card class="tracking-panel tracking-panel--citation" :loading="dashboardQuery.isLoading.value">
|
||||||
<div class="tracking-citation-section">
|
<div class="tracking-citation-section">
|
||||||
<div class="tracking-citation-header">
|
<div class="tracking-citation-header">
|
||||||
@@ -1018,6 +906,34 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
|
<a-card class="tracking-panel" :loading="dashboardQuery.isLoading.value">
|
||||||
|
<div class="tracking-panel__header">
|
||||||
|
<h3 class="panel-title">{{ t("tracking.hotQuestionsTitle") }}</h3>
|
||||||
|
<span class="tracking-panel__helper">{{ t("tracking.hotQuestionsHint") }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hotQuestions.length" class="question-list">
|
||||||
|
<button
|
||||||
|
v-for="question in hotQuestions"
|
||||||
|
:key="`${question.question_id}-${question.question_hash}`"
|
||||||
|
type="button"
|
||||||
|
class="question-list__item"
|
||||||
|
@click="openQuestion(question)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>{{ question.question_text }}</strong>
|
||||||
|
<p>{{ t("tracking.metrics.mentionRate") }} {{ formatPercent(question.mention_rate) }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="question-list__time">{{ question.last_sampled_at ? formatDateTime(question.last_sampled_at) : '--' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty v-else :description="t('tracking.noQuestions')" />
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tracking-grid tracking-grid--bottom">
|
||||||
|
|
||||||
<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">
|
||||||
<h3 class="panel-title">{{ t("tracking.citedArticlesTitle") }}</h3>
|
<h3 class="panel-title">{{ t("tracking.citedArticlesTitle") }}</h3>
|
||||||
@@ -1318,30 +1234,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card__sparkline {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: 22px;
|
|
||||||
margin-top: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card__point {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card__point--partial {
|
|
||||||
opacity: 0.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card__point--empty {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid #1677ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.platform-table,
|
.platform-table,
|
||||||
.citation-table {
|
.citation-table {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1512,29 +1404,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
|||||||
color: #111827;
|
color: #111827;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tracking-day-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-day-dot {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid #fdba74;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-day-dot--sampled {
|
|
||||||
background: #f97316;
|
|
||||||
border-color: #f97316;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tracking-day-dot--partial {
|
|
||||||
background: linear-gradient(90deg, #f97316 50%, transparent 50%);
|
|
||||||
}
|
|
||||||
.question-list,
|
.question-list,
|
||||||
.article-list,
|
.article-list,
|
||||||
.detail-citation-list {
|
.detail-citation-list {
|
||||||
|
|||||||
@@ -1085,22 +1085,6 @@ export interface MonitoringPlatformBreakdownItem {
|
|||||||
last_sampled_at: string | null;
|
last_sampled_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringTimeBucket {
|
|
||||||
date: string;
|
|
||||||
sample_status: string;
|
|
||||||
metric_value: number | null;
|
|
||||||
mention_rate: number | null;
|
|
||||||
top1_mention_rate: number | null;
|
|
||||||
first_recommend_rate: number | null;
|
|
||||||
positive_mention_rate: number | null;
|
|
||||||
citation_rate: number | null;
|
|
||||||
planned_sample_count: number;
|
|
||||||
actual_sample_count: number;
|
|
||||||
coverage_rate: number | null;
|
|
||||||
trigger_source: string | null;
|
|
||||||
snapshot_updated_at: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MonitoringHotQuestion {
|
export interface MonitoringHotQuestion {
|
||||||
question_id: number;
|
question_id: number;
|
||||||
question_hash: string;
|
question_hash: string;
|
||||||
@@ -1134,7 +1118,6 @@ export interface MonitoringDashboardCompositeResponse {
|
|||||||
overview: MonitoringOverview;
|
overview: MonitoringOverview;
|
||||||
runtime: MonitoringDashboardRuntime;
|
runtime: MonitoringDashboardRuntime;
|
||||||
platform_breakdown: MonitoringPlatformBreakdownItem[];
|
platform_breakdown: MonitoringPlatformBreakdownItem[];
|
||||||
brand_time_buckets: MonitoringTimeBucket[];
|
|
||||||
hot_questions: MonitoringHotQuestion[];
|
hot_questions: MonitoringHotQuestion[];
|
||||||
citation_ranking: MonitoringCitationRankingItem[];
|
citation_ranking: MonitoringCitationRankingItem[];
|
||||||
cited_articles: MonitoringCitedArticle[];
|
cited_articles: MonitoringCitedArticle[];
|
||||||
@@ -1163,6 +1146,7 @@ export interface MonitoringQuestionDetailPlatform {
|
|||||||
brand_mentioned: boolean | null;
|
brand_mentioned: boolean | null;
|
||||||
brand_mention_position: string | null;
|
brand_mention_position: string | null;
|
||||||
citations: MonitoringQuestionDetailCitation[];
|
citations: MonitoringQuestionDetailCitation[];
|
||||||
|
inline_citations?: MonitoringSourceItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MonitoringQuestionCitationAnalysisItem {
|
export interface MonitoringQuestionCitationAnalysisItem {
|
||||||
|
|||||||
@@ -1683,6 +1683,8 @@ func (s *MonitoringCallbackService) upsertParseResult(ctx context.Context, tx pg
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MonitoringCallbackService) processTaskResult(ctx context.Context, task *monitoringCollectTask, req MonitoringTaskResultRequest) (*MonitoringTaskResultResponse, error) {
|
func (s *MonitoringCallbackService) processTaskResult(ctx context.Context, task *monitoringCollectTask, req MonitoringTaskResultRequest) (*MonitoringTaskResultResponse, error) {
|
||||||
|
req = normalizeMonitoringTaskResultRequestValue(req)
|
||||||
|
|
||||||
runStatus := normalizeRunStatus(req.Status)
|
runStatus := normalizeRunStatus(req.Status)
|
||||||
if runStatus == "" {
|
if runStatus == "" {
|
||||||
return nil, response.ErrBadRequest(40041, "invalid_status", "status must be succeeded or failed")
|
return nil, response.ErrBadRequest(40041, "invalid_status", "status must be succeeded or failed")
|
||||||
@@ -1703,7 +1705,7 @@ func (s *MonitoringCallbackService) processTaskResult(ctx context.Context, task
|
|||||||
completedAt = req.CompletedAt.UTC().Round(0)
|
completedAt = req.CompletedAt.UTC().Round(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
rawPayload, sourceInputs := buildMonitoringRawPayload(req)
|
rawPayload, sourceInputs := buildMonitoringRawPayload(task.AIPlatformID, req)
|
||||||
|
|
||||||
tx, err := s.monitoringPool.Begin(ctx)
|
tx, err := s.monitoringPool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2280,7 +2282,7 @@ func (s *MonitoringCallbackService) PatchTaskResultQueueMeta(taskID int64, recei
|
|||||||
s.tryPatchTaskResultQueueMeta(taskID, receivedAt, patch)
|
s.tryPatchTaskResultQueueMeta(taskID, receivedAt, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []MonitoringSourceItem) {
|
func buildMonitoringRawPayload(platformID string, req MonitoringTaskResultRequest) ([]byte, []MonitoringSourceItem) {
|
||||||
payload := make(map[string]interface{}, len(req.RawResponseJSON)+4)
|
payload := make(map[string]interface{}, len(req.RawResponseJSON)+4)
|
||||||
for key, value := range req.RawResponseJSON {
|
for key, value := range req.RawResponseJSON {
|
||||||
payload[key] = value
|
payload[key] = value
|
||||||
@@ -2299,16 +2301,7 @@ func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []Monit
|
|||||||
payload["status"] = strings.TrimSpace(req.Status)
|
payload["status"] = strings.TrimSpace(req.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceInputs := append([]MonitoringSourceItem{}, req.Citations...)
|
sourceInputs := buildMonitoringCitationSourceInputs(platformID, req, payload)
|
||||||
sourceInputs = append(sourceInputs, req.SearchResults...)
|
|
||||||
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["citations"])...)
|
|
||||||
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["references"])...)
|
|
||||||
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["sources"])...)
|
|
||||||
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thought_references"])...)
|
|
||||||
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thinking_references"])...)
|
|
||||||
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["search_results"])...)
|
|
||||||
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["searchResults"])...)
|
|
||||||
|
|
||||||
if len(payload) == 0 {
|
if len(payload) == 0 {
|
||||||
return nil, sourceInputs
|
return nil, sourceInputs
|
||||||
}
|
}
|
||||||
@@ -2320,6 +2313,34 @@ func buildMonitoringRawPayload(req MonitoringTaskResultRequest) ([]byte, []Monit
|
|||||||
return data, sourceInputs
|
return data, sourceInputs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buildMonitoringCitationSourceInputs(platformID string, req MonitoringTaskResultRequest, payload map[string]interface{}) []MonitoringSourceItem {
|
||||||
|
includeSearchResults := !monitoringPlatformUsesDedicatedCitationPanel(platformID)
|
||||||
|
|
||||||
|
sourceInputs := append([]MonitoringSourceItem{}, req.Citations...)
|
||||||
|
if includeSearchResults {
|
||||||
|
sourceInputs = append(sourceInputs, req.SearchResults...)
|
||||||
|
}
|
||||||
|
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["citations"])...)
|
||||||
|
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["references"])...)
|
||||||
|
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["sources"])...)
|
||||||
|
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thought_references"])...)
|
||||||
|
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["thinking_references"])...)
|
||||||
|
if includeSearchResults {
|
||||||
|
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["search_results"])...)
|
||||||
|
sourceInputs = append(sourceInputs, extractMonitoringSourceItems(payload["searchResults"])...)
|
||||||
|
}
|
||||||
|
return sourceInputs
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringPlatformUsesDedicatedCitationPanel(platformID string) bool {
|
||||||
|
switch normalizeMonitoringPlatformID(platformID) {
|
||||||
|
case "kimi":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newMonitoringTaskResultEnvelope(task *monitoringCollectTask, installationID string, receivedAt time.Time, req MonitoringTaskResultRequest) monitoringTaskResultEnvelope {
|
func newMonitoringTaskResultEnvelope(task *monitoringCollectTask, installationID string, receivedAt time.Time, req MonitoringTaskResultRequest) monitoringTaskResultEnvelope {
|
||||||
return monitoringTaskResultEnvelope{
|
return monitoringTaskResultEnvelope{
|
||||||
Version: "v1",
|
Version: "v1",
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildMonitoringRawPayloadKimiExcludesSearchResultsFromCitationInputs(t *testing.T) {
|
||||||
|
citationTitle := "真实引用文章"
|
||||||
|
searchTitle := "搜索网页结果"
|
||||||
|
|
||||||
|
req := MonitoringTaskResultRequest{
|
||||||
|
Status: "succeeded",
|
||||||
|
Citations: []MonitoringSourceItem{
|
||||||
|
{
|
||||||
|
URL: "https://example.com/citation",
|
||||||
|
Title: &citationTitle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SearchResults: []MonitoringSourceItem{
|
||||||
|
{
|
||||||
|
URL: "https://example.com/search",
|
||||||
|
Title: &searchTitle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
rawPayload, sourceInputs := buildMonitoringRawPayload("kimi", req)
|
||||||
|
if len(sourceInputs) != 1 {
|
||||||
|
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs))
|
||||||
|
}
|
||||||
|
for _, item := range sourceInputs {
|
||||||
|
if item.URL != "https://example.com/citation" {
|
||||||
|
t.Fatalf("buildMonitoringRawPayload() source url = %q, want citation url", item.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rawPayload, &payload); err != nil {
|
||||||
|
t.Fatalf("json.Unmarshal(rawPayload) error = %v", err)
|
||||||
|
}
|
||||||
|
if got := len(extractMonitoringSourceItems(payload["search_results"])); got != 1 {
|
||||||
|
t.Fatalf("stored search_results len = %d, want 1", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMonitoringRawPayloadQwenIncludesSearchResultsInCitationInputs(t *testing.T) {
|
||||||
|
searchTitle := "搜索网页结果"
|
||||||
|
|
||||||
|
req := MonitoringTaskResultRequest{
|
||||||
|
Status: "succeeded",
|
||||||
|
SearchResults: []MonitoringSourceItem{
|
||||||
|
{
|
||||||
|
URL: "https://example.com/search",
|
||||||
|
Title: &searchTitle,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, sourceInputs := buildMonitoringRawPayload("qwen", req)
|
||||||
|
if len(sourceInputs) != 1 {
|
||||||
|
t.Fatalf("buildMonitoringRawPayload() sourceInputs len = %d, want 1", len(sourceInputs))
|
||||||
|
}
|
||||||
|
for _, item := range sourceInputs {
|
||||||
|
if item.URL != "https://example.com/search" {
|
||||||
|
t.Fatalf("buildMonitoringRawPayload() source url = %q, want search url", item.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractMonitoringInlineCitationsFromRawResponseKimi(t *testing.T) {
|
||||||
|
raw := []byte(`{
|
||||||
|
"inline_citation_candidates": [
|
||||||
|
{
|
||||||
|
"url": "https://example.com/a#ref",
|
||||||
|
"title": "文章 A",
|
||||||
|
"site_name": "示例站点"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://example.com/a#other",
|
||||||
|
"title": "文章 A",
|
||||||
|
"site_name": "示例站点"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://example.com/b",
|
||||||
|
"title": "文章 B",
|
||||||
|
"site_name": "另一个站点"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
|
||||||
|
items := extractMonitoringInlineCitationsFromRawResponse(raw, "kimi")
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() len = %d, want 2", len(items))
|
||||||
|
}
|
||||||
|
if items[0].URL != "https://example.com/a" {
|
||||||
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() first url = %q, want https://example.com/a", items[0].URL)
|
||||||
|
}
|
||||||
|
if items[1].URL != "https://example.com/b" {
|
||||||
|
t.Fatalf("extractMonitoringInlineCitationsFromRawResponse() second url = %q, want https://example.com/b", items[1].URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ func (s *MonitoringCallbackService) rebuildBrandDailyProjection(ctx context.Cont
|
|||||||
|
|
||||||
desiredSampleCount := enabledQuestionCount * int64(len(platforms))
|
desiredSampleCount := enabledQuestionCount * int64(len(platforms))
|
||||||
coverageRate := divideAsPointer(brandAgg.ActualSampleCount, brandAgg.PlannedSampleCount)
|
coverageRate := divideAsPointer(brandAgg.ActualSampleCount, brandAgg.PlannedSampleCount)
|
||||||
mentionRate := averageMonitoringPlatformMentionRate(platformAggs)
|
mentionRate := divideAsPointer(brandAgg.MentionedCount, brandAgg.ActualSampleCount)
|
||||||
top1MentionRate := divideAsPointer(brandAgg.Top1MentionedCount, brandAgg.ActualSampleCount)
|
top1MentionRate := divideAsPointer(brandAgg.Top1MentionedCount, brandAgg.ActualSampleCount)
|
||||||
firstRecommendRate := divideAsPointer(brandAgg.FirstRecommendedCount, brandAgg.ActualSampleCount)
|
firstRecommendRate := divideAsPointer(brandAgg.FirstRecommendedCount, brandAgg.ActualSampleCount)
|
||||||
positiveMentionRate := divideAsPointer(brandAgg.PositiveMentionedCount, brandAgg.ActualSampleCount)
|
positiveMentionRate := divideAsPointer(brandAgg.PositiveMentionedCount, brandAgg.ActualSampleCount)
|
||||||
@@ -151,7 +151,7 @@ func loadMonitoringProjectionPlatforms(ctx context.Context, businessPool *pgxpoo
|
|||||||
if len(enabledJSON) > 0 {
|
if len(enabledJSON) > 0 {
|
||||||
var configured []string
|
var configured []string
|
||||||
if jsonErr := json.Unmarshal(enabledJSON, &configured); jsonErr == nil && len(configured) > 0 {
|
if jsonErr := json.Unmarshal(enabledJSON, &configured); jsonErr == nil && len(configured) > 0 {
|
||||||
enabled = configured
|
enabled = monitoringPlatformIDs(defaultMonitoringPlatforms)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,29 +372,6 @@ func (s *MonitoringCallbackService) loadMonitoringPlatformDailyAggregates(ctx co
|
|||||||
return items, nil
|
return items, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func averageMonitoringPlatformMentionRate(items []monitoringPlatformDailyAggregate) *float64 {
|
|
||||||
if len(items) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
total := 0.0
|
|
||||||
count := 0
|
|
||||||
for _, item := range items {
|
|
||||||
rate := divideAsPointer(item.MentionedCount, item.ActualSampleCount)
|
|
||||||
if rate == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
total += *rate
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
average := total / float64(count)
|
|
||||||
return &average
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *MonitoringCallbackService) replaceMonitoringPlatformDailyProjection(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, businessDate time.Time, items []monitoringPlatformDailyAggregate) error {
|
func (s *MonitoringCallbackService) replaceMonitoringPlatformDailyProjection(ctx context.Context, tx pgx.Tx, tenantID, brandID int64, businessDate time.Time, items []monitoringPlatformDailyAggregate) error {
|
||||||
dateText := monitoringBusinessDateText(businessDate)
|
dateText := monitoringBusinessDateText(businessDate)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -93,3 +93,68 @@ func TestFilterMonitoringPlatformsFallsBackToDisplayName(t *testing.T) {
|
|||||||
{ID: "doubao", Name: "豆包"},
|
{ID: "doubao", Name: "豆包"},
|
||||||
}, filtered)
|
}, filtered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMonitoringPlatformIDCanonicalizesLatestIDs(t *testing.T) {
|
||||||
|
assert.Equal(t, "qwen", normalizeMonitoringPlatformID(" QWEN "))
|
||||||
|
assert.Equal(t, "wenxin", normalizeMonitoringPlatformID("WenXin"))
|
||||||
|
assert.Equal(t, "yuanbao", normalizeMonitoringPlatformID("yuanbao"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterMonitoringPlatformsMatchesCanonicalPlatform(t *testing.T) {
|
||||||
|
platforms := []monitoringPlatformMetadata{
|
||||||
|
{ID: "qwen", Name: "通义千问"},
|
||||||
|
}
|
||||||
|
selected := "qwen"
|
||||||
|
|
||||||
|
filtered := filterMonitoringPlatforms(platforms, &selected)
|
||||||
|
|
||||||
|
assert.Equal(t, []monitoringPlatformMetadata{
|
||||||
|
{ID: "qwen", Name: "通义千问"},
|
||||||
|
}, filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntersectMonitoringPlatformsAcceptsCanonicalIDs(t *testing.T) {
|
||||||
|
enabled := []monitoringPlatformMetadata{
|
||||||
|
{ID: "qwen", Name: "通义千问"},
|
||||||
|
{ID: "wenxin", Name: "文心一言"},
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered, err := intersectMonitoringPlatforms(enabled, []string{"qwen", "wenxin"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []monitoringPlatformMetadata{
|
||||||
|
{ID: "qwen", Name: "通义千问"},
|
||||||
|
{ID: "wenxin", Name: "文心一言"},
|
||||||
|
}, filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileEnabledMonitoringPlatformsBackfillsSupportedCatalog(t *testing.T) {
|
||||||
|
assert.Equal(t, []string{"yuanbao", "kimi", "wenxin", "deepseek", "doubao", "qwen"}, reconcileEnabledMonitoringPlatforms([]string{"deepseek", "qwen", "doubao"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDerivedRatesToOverviewSnapshotUsesAllSamplesForMentionRate(t *testing.T) {
|
||||||
|
day := time.Date(2026, 4, 22, 0, 0, 0, 0, monitoringBusinessLocation())
|
||||||
|
snapshot := monitoringOverviewSnapshot{Date: day}
|
||||||
|
derived := newMonitoringDerivedMetrics()
|
||||||
|
derived.ByDate["2026-04-22"] = monitoringDerivedRateStats{
|
||||||
|
Total: 4,
|
||||||
|
MentionedCount: 1,
|
||||||
|
Top1Count: 1,
|
||||||
|
FirstRecommend: 1,
|
||||||
|
PositiveMentions: 1,
|
||||||
|
}
|
||||||
|
derived.ByDatePlatform["2026-04-22"] = map[string]monitoringDerivedRateStats{
|
||||||
|
"qwen": {Total: 1, MentionedCount: 1},
|
||||||
|
"deepseek": {Total: 3, MentionedCount: 0},
|
||||||
|
}
|
||||||
|
|
||||||
|
applyDerivedRatesToOverviewSnapshot(&snapshot, derived)
|
||||||
|
|
||||||
|
require.True(t, snapshot.MentionRate.Valid)
|
||||||
|
require.True(t, snapshot.Top1MentionRate.Valid)
|
||||||
|
require.True(t, snapshot.FirstRecommendRate.Valid)
|
||||||
|
require.True(t, snapshot.PositiveMentionRate.Valid)
|
||||||
|
assert.InDelta(t, 0.25, snapshot.MentionRate.Float64, 0.0001)
|
||||||
|
assert.InDelta(t, 0.25, snapshot.Top1MentionRate.Float64, 0.0001)
|
||||||
|
assert.InDelta(t, 0.25, snapshot.FirstRecommendRate.Float64, 0.0001)
|
||||||
|
assert.InDelta(t, 0.25, snapshot.PositiveMentionRate.Float64, 0.0001)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
var monitoringWindows1252ReverseMap = map[rune]byte{
|
||||||
|
0x20AC: 0x80,
|
||||||
|
0x201A: 0x82,
|
||||||
|
0x0192: 0x83,
|
||||||
|
0x201E: 0x84,
|
||||||
|
0x2026: 0x85,
|
||||||
|
0x2020: 0x86,
|
||||||
|
0x2021: 0x87,
|
||||||
|
0x02C6: 0x88,
|
||||||
|
0x2030: 0x89,
|
||||||
|
0x0160: 0x8A,
|
||||||
|
0x2039: 0x8B,
|
||||||
|
0x0152: 0x8C,
|
||||||
|
0x017D: 0x8E,
|
||||||
|
0x2018: 0x91,
|
||||||
|
0x2019: 0x92,
|
||||||
|
0x201C: 0x93,
|
||||||
|
0x201D: 0x94,
|
||||||
|
0x2022: 0x95,
|
||||||
|
0x2013: 0x96,
|
||||||
|
0x2014: 0x97,
|
||||||
|
0x02DC: 0x98,
|
||||||
|
0x2122: 0x99,
|
||||||
|
0x0161: 0x9A,
|
||||||
|
0x203A: 0x9B,
|
||||||
|
0x0153: 0x9C,
|
||||||
|
0x017E: 0x9E,
|
||||||
|
0x0178: 0x9F,
|
||||||
|
}
|
||||||
|
|
||||||
|
func repairMonitoringMojibake(value string) string {
|
||||||
|
trimmed := strings.TrimSpace(value)
|
||||||
|
if trimmed == "" || !looksLikeMonitoringMojibake(trimmed) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
repaired, ok := decodeMonitoringLatin1AsUTF8(trimmed)
|
||||||
|
if !ok || strings.ContainsRune(repaired, utf8.RuneError) || !monitoringRepairLooksBetter(trimmed, repaired) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return repaired
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMonitoringOptionalString(value *string) *string {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
repaired := repairMonitoringMojibake(*value)
|
||||||
|
return &repaired
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeMonitoringMojibake(value string) bool {
|
||||||
|
if value == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(value, "Ã") || strings.Contains(value, "â€") || strings.Contains(value, "ï¼") || strings.Contains(value, "ã€") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
hanCount := monitoringHanRuneCount(value)
|
||||||
|
suspiciousCount := monitoringSuspiciousRuneCount(value)
|
||||||
|
if hanCount > 0 {
|
||||||
|
return suspiciousCount >= 8 && suspiciousCount > hanCount*3
|
||||||
|
}
|
||||||
|
return suspiciousCount >= 4
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeMonitoringLatin1AsUTF8(value string) (string, bool) {
|
||||||
|
buf := make([]byte, 0, len(value))
|
||||||
|
for _, r := range value {
|
||||||
|
if mapped, ok := monitoringWindows1252ReverseMap[r]; ok {
|
||||||
|
buf = append(buf, mapped)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r > 255 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
buf = append(buf, byte(r))
|
||||||
|
}
|
||||||
|
if !utf8.Valid(buf) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return string(buf), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringRepairLooksBetter(original, repaired string) bool {
|
||||||
|
if strings.TrimSpace(repaired) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
originalHan := monitoringHanRuneCount(original)
|
||||||
|
repairedHan := monitoringHanRuneCount(repaired)
|
||||||
|
originalSuspicious := monitoringSuspiciousRuneCount(original)
|
||||||
|
repairedSuspicious := monitoringSuspiciousRuneCount(repaired)
|
||||||
|
|
||||||
|
if repairedHan > originalHan && repairedHan >= 2 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return repairedHan > 0 && repairedSuspicious*2 < originalSuspicious
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringHanRuneCount(value string) int {
|
||||||
|
count := 0
|
||||||
|
for _, r := range value {
|
||||||
|
if unicode.Is(unicode.Han, r) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func monitoringSuspiciousRuneCount(value string) int {
|
||||||
|
count := 0
|
||||||
|
for _, r := range value {
|
||||||
|
if (r >= 0x00C0 && r <= 0x017F) || (r >= 0x2000 && r <= 0x20FF) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMonitoringSourceItemValue(item MonitoringSourceItem) MonitoringSourceItem {
|
||||||
|
item.Title = normalizeMonitoringOptionalString(item.Title)
|
||||||
|
item.SiteName = normalizeMonitoringOptionalString(item.SiteName)
|
||||||
|
item.ResolutionStatus = normalizeMonitoringOptionalString(item.ResolutionStatus)
|
||||||
|
item.ResolutionConfidence = normalizeMonitoringOptionalString(item.ResolutionConfidence)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMonitoringTaskResultRequestValue(req MonitoringTaskResultRequest) MonitoringTaskResultRequest {
|
||||||
|
if req.Answer != nil {
|
||||||
|
repaired := repairMonitoringMojibake(*req.Answer)
|
||||||
|
req.Answer = &repaired
|
||||||
|
}
|
||||||
|
if req.ErrorMessage != nil {
|
||||||
|
repaired := repairMonitoringMojibake(*req.ErrorMessage)
|
||||||
|
req.ErrorMessage = &repaired
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.Citations) > 0 {
|
||||||
|
next := make([]MonitoringSourceItem, 0, len(req.Citations))
|
||||||
|
for _, item := range req.Citations {
|
||||||
|
next = append(next, normalizeMonitoringSourceItemValue(item))
|
||||||
|
}
|
||||||
|
req.Citations = next
|
||||||
|
}
|
||||||
|
if len(req.SearchResults) > 0 {
|
||||||
|
next := make([]MonitoringSourceItem, 0, len(req.SearchResults))
|
||||||
|
for _, item := range req.SearchResults {
|
||||||
|
next = append(next, normalizeMonitoringSourceItemValue(item))
|
||||||
|
}
|
||||||
|
req.SearchResults = next
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(req.RawResponseJSON) > 0 {
|
||||||
|
next := make(map[string]interface{}, len(req.RawResponseJSON))
|
||||||
|
for key, value := range req.RawResponseJSON {
|
||||||
|
next[key] = normalizeMonitoringJSONValue(value)
|
||||||
|
}
|
||||||
|
req.RawResponseJSON = next
|
||||||
|
}
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeMonitoringJSONValue(value interface{}) interface{} {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return repairMonitoringMojibake(typed)
|
||||||
|
case []interface{}:
|
||||||
|
next := make([]interface{}, len(typed))
|
||||||
|
for index, item := range typed {
|
||||||
|
next[index] = normalizeMonitoringJSONValue(item)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
case map[string]interface{}:
|
||||||
|
next := make(map[string]interface{}, len(typed))
|
||||||
|
for key, item := range typed {
|
||||||
|
next[key] = normalizeMonitoringJSONValue(item)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestRepairMonitoringMojibake(t *testing.T) {
|
||||||
|
input := "用户询问åˆè‚¥å…¨å±‹å®šåˆ¶çš„æ€§ä»·æ¯”最高的å“牌"
|
||||||
|
got := repairMonitoringMojibake(input)
|
||||||
|
want := "用户询问合肥全屋定制的性价比最高的品牌"
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("repairMonitoringMojibake() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairMonitoringMojibakeLeavesNormalChineseUntouched(t *testing.T) {
|
||||||
|
input := "用户询问合肥全屋定制的性价比最高的品牌"
|
||||||
|
got := repairMonitoringMojibake(input)
|
||||||
|
if got != input {
|
||||||
|
t.Fatalf("repairMonitoringMojibake() changed valid text: got %q, want %q", got, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user