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:
@@ -10,7 +10,6 @@ import type {
|
||||
Brand,
|
||||
DesktopAccountInfo,
|
||||
MonitoringHotQuestion,
|
||||
MonitoringTimeBucket,
|
||||
} from "@geo/shared-types";
|
||||
import { aiPlatformCatalog } from "@geo/shared-types";
|
||||
import { computed, watch } from "vue";
|
||||
@@ -21,6 +20,7 @@ import PageHero from "@/components/PageHero.vue";
|
||||
import { brandsApi, monitoringApi, tenantAccountsApi } from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/display";
|
||||
import { formatError } from "@/lib/errors";
|
||||
import { isMonitoringPlatformId, normalizeMonitoringPlatformFilter, normalizeMonitoringPlatformId } from "@/lib/monitoring-platforms";
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
@@ -76,10 +76,7 @@ const platformAppearanceMap: Record<string, { color: string; surface: string; gl
|
||||
doubao: { color: "#ff9348", surface: "#fff2e8", glyph: "豆" },
|
||||
deepseek: { color: "#5d72ff", surface: "#eef1ff", glyph: "D" },
|
||||
qwen: { color: "#276ef1", surface: "#edf4ff", glyph: "通" },
|
||||
tongyi: { color: "#6d5efc", surface: "#f1efff", glyph: "通" },
|
||||
tongyiqianwen: { color: "#6d5efc", surface: "#f1efff", glyph: "通" },
|
||||
wenxin: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
|
||||
wenxinyiyan: { color: "#20c6d7", surface: "#ebfcff", glyph: "文" },
|
||||
yuanbao: { color: "#19bf6b", surface: "#eafbf1", glyph: "元" },
|
||||
kimi: { color: "#111827", surface: "#f3f4f6", glyph: "K" },
|
||||
};
|
||||
@@ -92,16 +89,12 @@ const trackingPlatformOptions = [
|
||||
})),
|
||||
] as const;
|
||||
|
||||
const trackingPlatformIds = new Set<string>(
|
||||
aiPlatformCatalog.map((item) => item.id),
|
||||
);
|
||||
|
||||
const selectedBrandId = useStorage<number | null>("tracking_selected_brand", null);
|
||||
const selectedKeywordId = useStorage<number | null>("tracking_selected_keyword", null);
|
||||
const selectedPlatformId = useStorage<string>("tracking_selected_ai_platform_id", "all");
|
||||
const selectedBusinessDate = useStorage<string>("tracking_selected_business_date", trackingToday);
|
||||
|
||||
selectedPlatformId.value = normalizeTrackingPlatformId(selectedPlatformId.value);
|
||||
selectedPlatformId.value = normalizeMonitoringPlatformFilter(selectedPlatformId.value);
|
||||
selectedBusinessDate.value = normalizeTrackingBusinessDate(selectedBusinessDate.value) ?? trackingToday;
|
||||
|
||||
const brandsQuery = useQuery({
|
||||
@@ -179,7 +172,7 @@ watch(
|
||||
watch(
|
||||
() => route.query.ai_platform_id,
|
||||
(value) => {
|
||||
selectedPlatformId.value = normalizeTrackingPlatformId(value);
|
||||
selectedPlatformId.value = normalizeMonitoringPlatformFilter(value);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -191,7 +184,7 @@ watch([selectedBrandId, selectedKeywordId, selectedPlatformId, selectedBusinessD
|
||||
const nextBusinessDate = normalizeTrackingBusinessDate(businessDate) ?? trackingToday;
|
||||
const currentBrandId = normalizeQueryValue(route.query.brand_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;
|
||||
|
||||
if (
|
||||
@@ -297,6 +290,9 @@ const collectNowDisabledReason = computed(() => {
|
||||
if (!selectedKeywordId.value) {
|
||||
return t("tracking.collectKeywordRequired");
|
||||
}
|
||||
if (selectedBusinessDate.value !== trackingToday) {
|
||||
return t("tracking.collectTodayOnly");
|
||||
}
|
||||
if (dashboardQuery.isLoading.value && !dashboardQuery.data.value) {
|
||||
return t("tracking.collectClientChecking");
|
||||
}
|
||||
@@ -317,16 +313,17 @@ const hotQuestions = computed(() => {
|
||||
const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => {
|
||||
const accountGroups = new Map<string, DesktopAccountInfo[]>();
|
||||
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;
|
||||
}
|
||||
const list = accountGroups.get(account.platform) ?? [];
|
||||
const list = accountGroups.get(platformId) ?? [];
|
||||
list.push(account);
|
||||
accountGroups.set(account.platform, list);
|
||||
accountGroups.set(platformId, list);
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -353,55 +350,34 @@ const aiPlatformStatusCards = computed<AIPlatformStatusCard[]>(() => {
|
||||
|
||||
const selectedMetricCards = computed(() => {
|
||||
const overview = dashboardQuery.data.value?.overview;
|
||||
const buckets = dashboardQuery.data.value?.brand_time_buckets ?? [];
|
||||
const points = selectedDateHasData.value ? buckets : [];
|
||||
return [
|
||||
{
|
||||
key: "mention",
|
||||
title: t("tracking.metrics.mentionRate"),
|
||||
value: overview?.mention_rate ?? null,
|
||||
previous: overview?.prev_snapshot_mention_rate ?? null,
|
||||
points: points.map((item) => item.mention_rate),
|
||||
},
|
||||
{
|
||||
key: "top1",
|
||||
title: t("tracking.metrics.top1MentionRate"),
|
||||
value: overview?.top1_mention_rate ?? null,
|
||||
previous: overview?.prev_snapshot_top1_mention_rate ?? null,
|
||||
points: points.map((item) => item.top1_mention_rate),
|
||||
},
|
||||
{
|
||||
key: "first",
|
||||
title: t("tracking.metrics.firstRecommendRate"),
|
||||
value: overview?.first_recommend_rate ?? null,
|
||||
previous: overview?.prev_snapshot_first_recommend_rate ?? null,
|
||||
points: points.map((item) => item.first_recommend_rate),
|
||||
},
|
||||
{
|
||||
key: "positive",
|
||||
title: t("tracking.metrics.positiveMentionRate"),
|
||||
value: overview?.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[]>(() => {
|
||||
if (!selectedDateHasData.value) {
|
||||
return [];
|
||||
@@ -413,32 +389,34 @@ const citationRankingRows = computed<CitationRankingRow[]>(() => {
|
||||
const rows = new Map<string, CitationRankingRow>();
|
||||
|
||||
breakdown.forEach((item, index) => {
|
||||
order.set(item.ai_platform_id, index);
|
||||
rows.set(item.ai_platform_id, {
|
||||
ai_platform_id: item.ai_platform_id,
|
||||
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||
order.set(platformId, index);
|
||||
rows.set(platformId, {
|
||||
ai_platform_id: platformId,
|
||||
platform_name: item.platform_name,
|
||||
cited_answer_count: null,
|
||||
cited_article_count: null,
|
||||
citation_rate: null,
|
||||
share: 0,
|
||||
...platformAppearance(item.ai_platform_id, item.platform_name),
|
||||
...platformAppearance(platformId, item.platform_name),
|
||||
});
|
||||
});
|
||||
|
||||
ranking.forEach((item, index) => {
|
||||
if (!order.has(item.ai_platform_id)) {
|
||||
order.set(item.ai_platform_id, breakdown.length + index);
|
||||
const platformId = normalizeMonitoringPlatformId(item.ai_platform_id) || item.ai_platform_id;
|
||||
if (!order.has(platformId)) {
|
||||
order.set(platformId, breakdown.length + index);
|
||||
}
|
||||
|
||||
const existing = rows.get(item.ai_platform_id);
|
||||
rows.set(item.ai_platform_id, {
|
||||
ai_platform_id: item.ai_platform_id,
|
||||
platform_name: item.platform_name || existing?.platform_name || item.ai_platform_id,
|
||||
cited_answer_count: item.cited_answer_count,
|
||||
cited_article_count: item.cited_article_count,
|
||||
citation_rate: item.citation_rate,
|
||||
const existing = rows.get(platformId);
|
||||
rows.set(platformId, {
|
||||
ai_platform_id: platformId,
|
||||
platform_name: item.platform_name || existing?.platform_name || platformId,
|
||||
cited_answer_count: (existing?.cited_answer_count ?? 0) + (item.cited_answer_count ?? 0),
|
||||
cited_article_count: (existing?.cited_article_count ?? 0) + (item.cited_article_count ?? 0),
|
||||
citation_rate: item.citation_rate ?? existing?.citation_rate ?? null,
|
||||
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)}%`;
|
||||
}
|
||||
|
||||
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 {
|
||||
switch (status) {
|
||||
case "sampled":
|
||||
@@ -633,7 +587,7 @@ function statusLabel(status: string): 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];
|
||||
|
||||
if (matched) {
|
||||
@@ -661,14 +615,6 @@ function normalizeQueryValue(value: unknown): string {
|
||||
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 {
|
||||
const normalized = normalizeQueryValue(value);
|
||||
if (!normalized) {
|
||||
@@ -784,13 +730,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
{{ formatDelta(card.value, card.previous) }}
|
||||
</span>
|
||||
</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>
|
||||
</div>
|
||||
</a-card>
|
||||
@@ -895,57 +834,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</a-card>
|
||||
|
||||
<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">
|
||||
<div class="tracking-citation-section">
|
||||
<div class="tracking-citation-header">
|
||||
@@ -1018,6 +906,34 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
</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" :loading="dashboardQuery.isLoading.value">
|
||||
<div class="tracking-panel__header">
|
||||
<h3 class="panel-title">{{ t("tracking.citedArticlesTitle") }}</h3>
|
||||
@@ -1318,30 +1234,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
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,
|
||||
.citation-table {
|
||||
display: flex;
|
||||
@@ -1512,29 +1404,6 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
|
||||
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,
|
||||
.article-list,
|
||||
.detail-citation-list {
|
||||
|
||||
Reference in New Issue
Block a user