162abdc97c
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2222 lines
61 KiB
Vue
2222 lines
61 KiB
Vue
<script setup lang="ts">
|
||
import { ArrowLeftOutlined, CopyOutlined } from '@ant-design/icons-vue'
|
||
import type {
|
||
MonitoringQuestionCitationAnalysisItem,
|
||
MonitoringQuestionDetailCitation,
|
||
MonitoringQuestionDetailPlatform,
|
||
MonitoringSourceItem,
|
||
} from '@geo/shared-types'
|
||
import { useQuery } from '@tanstack/vue-query'
|
||
import dayjs from 'dayjs'
|
||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
|
||
import MarkdownPreview from '@/components/MarkdownPreview.vue'
|
||
import { monitoringApi } from '@/lib/api'
|
||
import { formatDateTime } from '@/lib/display'
|
||
import { formatError } from '@/lib/errors'
|
||
import { isMonitoringPlatformId, normalizeMonitoringPlatformId } from '@/lib/monitoring-platforms'
|
||
|
||
const { t } = useI18n()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
|
||
const trackingMaxHistoryDays = 30
|
||
const trackingToday = dayjs().format('YYYY-MM-DD')
|
||
|
||
const brandId = computed(() => parsePositiveNumber(route.params.brandId))
|
||
const questionId = computed(() => parsePositiveNumber(route.params.questionId))
|
||
const questionHash = computed(() => normalizeQueryValue(route.query.question_hash))
|
||
const questionTitleFallback = computed(() => normalizeQueryValue(route.query.question_text))
|
||
const keywordId = computed(() => normalizeQueryValue(route.query.keyword_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 dateFrom = computed(() => {
|
||
return normalizeQueryValue(route.query.date_from) || businessDate.value
|
||
})
|
||
const dateTo = computed(() => {
|
||
return normalizeQueryValue(route.query.date_to) || businessDate.value
|
||
})
|
||
|
||
const detailQuery = useQuery({
|
||
queryKey: computed(() => [
|
||
'tracking',
|
||
'question-detail-page',
|
||
brandId.value,
|
||
questionId.value,
|
||
questionHash.value,
|
||
requestedPlatformId.value,
|
||
dateFrom.value,
|
||
dateTo.value,
|
||
]),
|
||
enabled: computed(() => Boolean(brandId.value && questionId.value)),
|
||
queryFn: () =>
|
||
monitoringApi.questionDetail(brandId.value as number, questionId.value as number, {
|
||
date_from: dateFrom.value,
|
||
date_to: dateTo.value,
|
||
question_hash: questionHash.value || undefined,
|
||
ai_platform_id: requestedPlatformId.value || undefined,
|
||
}),
|
||
})
|
||
|
||
const activePlatformId = ref('')
|
||
const detailPlatforms = computed<MonitoringQuestionDetailPlatform[]>(
|
||
() => detailQuery.data.value?.platforms ?? [],
|
||
)
|
||
const hasNoDetailPlatforms = computed(() => {
|
||
return Boolean(detailQuery.data.value) && detailPlatforms.value.length === 0
|
||
})
|
||
const shouldShowDetailUnavailableNotice = computed(() => {
|
||
return hasNoDetailPlatforms.value
|
||
})
|
||
const detailUnavailableNoticeType = computed<'warning' | 'info'>(() => {
|
||
return 'info'
|
||
})
|
||
const detailUnavailableNoticeTitle = computed(() => {
|
||
return requestedPlatformId.value
|
||
? t('tracking.selectedPlatformUnauthorizedTitle')
|
||
: t('tracking.noDetailPlatformsTitle')
|
||
})
|
||
const detailUnavailableNoticeDescription = computed(() => {
|
||
return requestedPlatformId.value
|
||
? t('tracking.selectedPlatformUnauthorizedDescription')
|
||
: t('tracking.noDetailPlatformsDescription')
|
||
})
|
||
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(
|
||
detailPlatforms,
|
||
(platforms) => {
|
||
if (!platforms?.length) {
|
||
activePlatformId.value = ''
|
||
return
|
||
}
|
||
|
||
const requestedPlatform = requestedPlatformId.value
|
||
? platforms.find((item) => item.ai_platform_id === requestedPlatformId.value)
|
||
: null
|
||
const sampledPlatform = platforms.find((item) => item.sample_status === 'sampled')
|
||
const nextPlatform = requestedPlatform ?? sampledPlatform ?? platforms[0]
|
||
|
||
if (!platforms.some((item) => item.ai_platform_id === activePlatformId.value)) {
|
||
activePlatformId.value = nextPlatform.ai_platform_id
|
||
}
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const pageTitle = computed(() => {
|
||
return (
|
||
detailQuery.data.value?.question_text ??
|
||
questionTitleFallback.value ??
|
||
t('tracking.questionDetailFallback')
|
||
)
|
||
})
|
||
|
||
const activePlatform = computed<MonitoringQuestionDetailPlatform | null>(
|
||
() =>
|
||
detailPlatforms.value.find((item) => item.ai_platform_id === activePlatformId.value) ?? null,
|
||
)
|
||
|
||
const activeCitationAnalysis = computed<MonitoringQuestionCitationAnalysisItem[]>(() => {
|
||
const items = detailCitationAnalysis.value
|
||
if (!activePlatformId.value) {
|
||
return items
|
||
}
|
||
return items.filter((item) => item.ai_platform_id === activePlatformId.value)
|
||
})
|
||
|
||
const latestSampledAt = computed<string | null>(() => {
|
||
const platforms = detailPlatforms.value
|
||
const sampledAtValues = platforms
|
||
.map((item) => item.sampled_at)
|
||
.filter((value): value is string => Boolean(value))
|
||
|
||
if (!sampledAtValues.length) {
|
||
return null
|
||
}
|
||
|
||
return sampledAtValues.reduce((latest, current) => {
|
||
if (!latest) {
|
||
return current
|
||
}
|
||
return dayjs(current).isAfter(dayjs(latest)) ? current : latest
|
||
}, sampledAtValues[0] ?? null)
|
||
})
|
||
|
||
const collectedAtDisplay = computed(() => {
|
||
const sampledAt = activePlatform.value?.sampled_at || latestSampledAt.value
|
||
return sampledAt ? formatDateTime(sampledAt) : '--'
|
||
})
|
||
|
||
type TrackingAnalyzedContentCitation = {
|
||
key: string
|
||
contentName: string
|
||
channelName: string
|
||
citationCount: number
|
||
citationRate: number | null
|
||
citedURL: string | null
|
||
}
|
||
|
||
type TrackingCitationSourceLookupItem = {
|
||
sourceGroupIndex: number
|
||
citation: MonitoringQuestionDetailCitation
|
||
}
|
||
|
||
const renderedAnswerContent = computed<string | null>(() => {
|
||
return formatAnswerContent(activePlatform.value)
|
||
})
|
||
|
||
const activeDeepSeekCitationReferenceLabels = computed<string[][]>(() => {
|
||
return collectDeepSeekCitationReferenceLabels(activePlatform.value)
|
||
})
|
||
|
||
const activeInlineContentCitations = computed<TrackingAnalyzedContentCitation[]>(() => {
|
||
return analyzeInlineContentCitations(activePlatform.value)
|
||
})
|
||
|
||
const shouldShowInlineContentCitations = computed(() => {
|
||
return activeInlineContentCitations.value.length > 0
|
||
})
|
||
|
||
watch(activePlatformId, () => {
|
||
clearCitationHighlight()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
clearCitationHighlight()
|
||
})
|
||
|
||
function goBack(): void {
|
||
void router.push({
|
||
name: 'tracking',
|
||
query: {
|
||
brand_id: brandId.value ? String(brandId.value) : undefined,
|
||
keyword_id: keywordId.value || undefined,
|
||
ai_platform_id: requestedPlatformId.value || undefined,
|
||
business_date: businessDate.value,
|
||
},
|
||
})
|
||
}
|
||
|
||
function openExternalURL(url: string | null | undefined): void {
|
||
const normalized = String(url ?? '').trim()
|
||
if (!normalized) {
|
||
return
|
||
}
|
||
window.open(normalized, '_blank', 'noopener,noreferrer')
|
||
}
|
||
|
||
function openImitationCreate(
|
||
sourceURL: string | null | undefined,
|
||
sourceTitle?: string | null,
|
||
): void {
|
||
const normalizedURL = String(sourceURL ?? '').trim()
|
||
if (!normalizedURL) {
|
||
return
|
||
}
|
||
|
||
void router.push({
|
||
name: 'article-imitation-create',
|
||
query: {
|
||
source_url: normalizedURL,
|
||
source_title: String(sourceTitle ?? '').trim() || undefined,
|
||
},
|
||
})
|
||
}
|
||
|
||
function formatPercent(value: number | null | undefined): string {
|
||
if (value === null || value === undefined) {
|
||
return '--'
|
||
}
|
||
return `${(value * 100).toFixed(2)}%`
|
||
}
|
||
|
||
function statusTagColor(status: string): string {
|
||
switch (status) {
|
||
case 'sampled':
|
||
return 'green'
|
||
case 'pending':
|
||
return 'gold'
|
||
case 'not_logged_in':
|
||
return 'default'
|
||
case 'unavailable':
|
||
return 'red'
|
||
default:
|
||
return 'default'
|
||
}
|
||
}
|
||
|
||
function statusLabel(status: string): string {
|
||
return t(`tracking.status.${status}`)
|
||
}
|
||
|
||
function resolveDeepSeekProviderModelLabel(value: string | null | undefined): string {
|
||
const normalized = String(value ?? '').trim()
|
||
if (!normalized || normalized === '--') {
|
||
return 'DeepSeek'
|
||
}
|
||
|
||
const compact = normalized.replace(/\s+/g, '').toLowerCase()
|
||
if (/r1|reasoner|deepthink|深度思考/.test(compact)) {
|
||
return 'DeepSeek'
|
||
}
|
||
|
||
const versionMatch =
|
||
normalized.match(/deepseek[-_\s]*(r1|v\d+(?:\.\d+)?)/i) ??
|
||
normalized.match(/\b(r1|v\d+(?:\.\d+)?)\b/i)
|
||
if (versionMatch?.[1]) {
|
||
return `DeepSeek-${versionMatch[1].toUpperCase()}`
|
||
}
|
||
|
||
if (/search|web|联网|搜索/.test(compact)) {
|
||
return 'DeepSeek'
|
||
}
|
||
|
||
return normalized
|
||
}
|
||
|
||
function resolveAnswerProviderModel(platform: MonitoringQuestionDetailPlatform | null): string {
|
||
if (isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) {
|
||
return resolveDeepSeekProviderModelLabel(platform?.provider_model)
|
||
}
|
||
|
||
return String(platform?.provider_model ?? '').trim() || '--'
|
||
}
|
||
|
||
function resolveCitationTitle(citation: MonitoringQuestionDetailCitation): string {
|
||
return citation.cited_title || citation.article_title || citation.cited_url
|
||
}
|
||
|
||
function isLinkedInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||
return platformId === 'yuanbao'
|
||
}
|
||
|
||
function isKimiInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||
return platformId === 'kimi'
|
||
}
|
||
|
||
function isDeepSeekInlineCitationPlatform(platformId: string | null | undefined): boolean {
|
||
return platformId === 'deepseek'
|
||
}
|
||
|
||
function supportsContentCitationDisplay(platformId: string | null | undefined): boolean {
|
||
return platformId !== 'qwen'
|
||
}
|
||
|
||
function createQwenSourceGroupPattern(): RegExp {
|
||
return /\[\[source_group_web_(\d+)\]\]/g
|
||
}
|
||
|
||
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 looksLikeHtmlAnswer(value: string | null | undefined): boolean {
|
||
const normalized = String(value ?? '').trim()
|
||
return /<\/?[a-z][^>]*>/i.test(normalized)
|
||
}
|
||
|
||
function parseHtmlFragment(content: string): HTMLElement | null {
|
||
if (!looksLikeHtmlAnswer(content)) {
|
||
return null
|
||
}
|
||
|
||
const parser = new DOMParser()
|
||
const document = parser.parseFromString(`<div>${content}</div>`, 'text/html')
|
||
return document.body.firstElementChild instanceof HTMLElement
|
||
? document.body.firstElementChild
|
||
: null
|
||
}
|
||
|
||
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 buildCitationSourceLookup(
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): Map<string, TrackingCitationSourceLookupItem> {
|
||
const lookup = new Map<string, TrackingCitationSourceLookupItem>()
|
||
for (const [index, citation] of (platform?.citations ?? []).entries()) {
|
||
const normalizedURL = normalizeInlineCitationUrl(citation.cited_url)
|
||
if (!normalizedURL || lookup.has(normalizedURL)) {
|
||
continue
|
||
}
|
||
lookup.set(normalizedURL, {
|
||
sourceGroupIndex: index + 1,
|
||
citation,
|
||
})
|
||
}
|
||
return lookup
|
||
}
|
||
|
||
function buildDeepSeekAnswerLinkSourceLookup(
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): Map<string, { title: string | null; siteName: string | null }> {
|
||
const lookup = new Map<string, { title: string | null; siteName: string | null }>()
|
||
|
||
for (const item of platform?.inline_citations ?? []) {
|
||
const normalizedURL = normalizeInlineCitationUrl(item.normalized_url ?? item.url)
|
||
if (!normalizedURL || lookup.has(normalizedURL)) {
|
||
continue
|
||
}
|
||
lookup.set(normalizedURL, {
|
||
title: item.title?.trim() || null,
|
||
siteName: item.site_name?.trim() || null,
|
||
})
|
||
}
|
||
|
||
for (const citation of platform?.citations ?? []) {
|
||
const normalizedURL = normalizeInlineCitationUrl(citation.cited_url)
|
||
if (!normalizedURL || lookup.has(normalizedURL)) {
|
||
continue
|
||
}
|
||
lookup.set(normalizedURL, {
|
||
title: resolveCitationTitle(citation),
|
||
siteName: citation.site_name?.trim() || null,
|
||
})
|
||
}
|
||
|
||
return lookup
|
||
}
|
||
|
||
function isHiddenDeepSeekAnswerNode(element: Element): boolean {
|
||
let current: Element | null = element
|
||
for (let depth = 0; current && depth < 8; depth += 1) {
|
||
if (current.hasAttribute('hidden') || current.getAttribute('aria-hidden') === 'true') {
|
||
return true
|
||
}
|
||
const style = current.getAttribute('style') ?? ''
|
||
if (/(display\s*:\s*none|visibility\s*:\s*hidden|opacity\s*:\s*0)/i.test(style)) {
|
||
return true
|
||
}
|
||
current = current.parentElement
|
||
}
|
||
return false
|
||
}
|
||
|
||
function isInsideDeepSeekAuxiliaryLinkContainer(anchor: HTMLAnchorElement): boolean {
|
||
let current = anchor.parentElement
|
||
for (let depth = 0; current && depth < 8; depth += 1) {
|
||
const descriptor = `${current.className || ''} ${current.getAttribute('data-testid') || ''} ${current.getAttribute('role') || ''}`
|
||
if (
|
||
/(search-view-card|search-result|result-card|source-card|reference-card|citation-card|popover|tooltip|dropdown|floating|hover-card|preview-card)/i.test(
|
||
descriptor,
|
||
)
|
||
) {
|
||
return true
|
||
}
|
||
current = current.parentElement
|
||
}
|
||
return false
|
||
}
|
||
|
||
function shouldCountDeepSeekAnswerLink(anchor: HTMLAnchorElement): boolean {
|
||
if (isHiddenDeepSeekAnswerNode(anchor) || isInsideDeepSeekAuxiliaryLinkContainer(anchor)) {
|
||
return false
|
||
}
|
||
|
||
const text = String(anchor.textContent ?? '').trim()
|
||
if (normalizeDeepSeekCitationLabel(text)) {
|
||
return true
|
||
}
|
||
if (shouldStripDeepSeekOrphanAnchor(anchor)) {
|
||
return false
|
||
}
|
||
return /[A-Za-z0-9\u3400-\u9fff]/.test(text)
|
||
}
|
||
|
||
function normalizeDeepSeekCitationLabel(value: string | null | undefined): string | null {
|
||
const normalized = String(value ?? '')
|
||
.trim()
|
||
.replace(/\s+/g, ' ')
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
|
||
const compact = normalized.replace(/\s+/g, '')
|
||
const numericCandidate =
|
||
compact.match(/^\[?(-?\d+)\]?$/)?.[1] ??
|
||
compact.match(/^[((【](-?\d+)[】))]$/)?.[1] ??
|
||
compact.match(/^#?(-?\d+)$/)?.[1] ??
|
||
null
|
||
if (numericCandidate) {
|
||
return `[${numericCandidate.replace(/^-/, '')}]`
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
function getOrCreateDeepSeekFallbackCitationLabel(
|
||
normalizedURL: string,
|
||
fallbackLabels: Map<string, string>,
|
||
state: { nextIndex: number },
|
||
): string {
|
||
const existing = fallbackLabels.get(normalizedURL)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
|
||
const nextLabel = `[${state.nextIndex}]`
|
||
fallbackLabels.set(normalizedURL, nextLabel)
|
||
state.nextIndex += 1
|
||
return nextLabel
|
||
}
|
||
|
||
function pushUniqueDeepSeekCitationLabel(labels: string[], label: string): void {
|
||
if (!labels.includes(label)) {
|
||
labels.push(label)
|
||
}
|
||
}
|
||
|
||
function shouldStripDeepSeekOrphanAnchor(anchor: HTMLAnchorElement): boolean {
|
||
const text = String(anchor.textContent ?? '')
|
||
.trim()
|
||
.replace(/\s+/g, '')
|
||
if (/^(link|source|citation|reference|open|原文|链接|来源|引用)$/i.test(text)) {
|
||
return true
|
||
}
|
||
if (/[A-Za-z0-9\u3400-\u9fff]/.test(text)) {
|
||
return false
|
||
}
|
||
if (anchor.querySelector('img, svg')) {
|
||
return true
|
||
}
|
||
return (
|
||
text.length === 0 ||
|
||
/^[\[\](){}<>#.,:;!?+\-_/\\|~`'"“”‘’·•::,。!?;、🔗↗↘↙↖↪⤴]+$/u.test(text)
|
||
)
|
||
}
|
||
|
||
function collectDeepSeekCitationReferenceLabels(
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): string[][] {
|
||
if (!isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) {
|
||
return []
|
||
}
|
||
|
||
const rawAnswer = String(platform?.answer_text ?? '').trim()
|
||
const root = rawAnswer ? parseHtmlFragment(rawAnswer) : null
|
||
if (!root) {
|
||
return (platform?.citations ?? []).map(() => [])
|
||
}
|
||
|
||
const citationSourceLookup = buildCitationSourceLookup(platform)
|
||
const labelsByUrl = new Map<string, string[]>()
|
||
const fallbackLabels = new Map<string, string>()
|
||
const fallbackState = { nextIndex: 1 }
|
||
|
||
for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]'))) {
|
||
const normalizedURL = normalizeInlineCitationUrl(
|
||
anchor.getAttribute('href') || anchor.href || '',
|
||
)
|
||
if (!normalizedURL || !citationSourceLookup.has(normalizedURL)) {
|
||
continue
|
||
}
|
||
|
||
const label =
|
||
normalizeDeepSeekCitationLabel(anchor.textContent) ??
|
||
getOrCreateDeepSeekFallbackCitationLabel(normalizedURL, fallbackLabels, fallbackState)
|
||
const labels = labelsByUrl.get(normalizedURL) ?? []
|
||
pushUniqueDeepSeekCitationLabel(labels, label)
|
||
labelsByUrl.set(normalizedURL, labels)
|
||
}
|
||
|
||
return (platform?.citations ?? []).map((citation) => {
|
||
const normalizedURL = normalizeInlineCitationUrl(citation.cited_url)
|
||
return normalizedURL ? (labelsByUrl.get(normalizedURL) ?? []) : []
|
||
})
|
||
}
|
||
|
||
function resolveCitationReferenceLabels(
|
||
citationIndex: number,
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): string[] {
|
||
if (!isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) {
|
||
return []
|
||
}
|
||
|
||
return activeDeepSeekCitationReferenceLabels.value[citationIndex] ?? []
|
||
}
|
||
|
||
function resolveCitationIndexDisplayLabels(
|
||
citationIndex: number,
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): string[] {
|
||
const labels = resolveCitationReferenceLabels(citationIndex, platform)
|
||
if (!labels.length) {
|
||
return []
|
||
}
|
||
|
||
const fallbackLabel = `[${citationIndex + 1}]`
|
||
if (labels.length === 1 && labels[0] === fallbackLabel) {
|
||
return []
|
||
}
|
||
return labels
|
||
}
|
||
|
||
function shouldShowCitationSourceListIndex(
|
||
citationIndex: number,
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): boolean {
|
||
if (!isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) {
|
||
return true
|
||
}
|
||
|
||
return resolveCitationIndexDisplayLabels(citationIndex, platform).length === 0
|
||
}
|
||
|
||
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()
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
|
||
if (looksLikeHtmlAnswer(normalized)) {
|
||
return normalized
|
||
}
|
||
|
||
const lines = normalized
|
||
.split(/\r?\n+/)
|
||
.map((line) => line.trim())
|
||
.filter(Boolean)
|
||
const keptLines = lines.filter((line) => !looksLikeNonAnswerNoise(line))
|
||
const sanitized = (keptLines.length ? keptLines.join('\n') : normalized)
|
||
.replace(/[ \t]+([,.;:!?,。!?;:])/g, '$1')
|
||
.replace(/[ \t]{2,}/g, ' ')
|
||
.replace(/\n{3,}/g, '\n\n')
|
||
.trim()
|
||
|
||
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 renderDeepSeekAnswerContent(
|
||
answerText: string,
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): string {
|
||
const root = parseHtmlFragment(answerText)
|
||
if (!root) {
|
||
return answerText
|
||
}
|
||
|
||
const inlineCitationLookup = buildInlineCitationSourceLookup(platform)
|
||
const citationSourceLookup = buildCitationSourceLookup(platform)
|
||
const fallbackLabels = new Map<string, string>()
|
||
const fallbackState = { nextIndex: 1 }
|
||
|
||
for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href], a'))) {
|
||
const rawHref = anchor.hasAttribute('href')
|
||
? anchor.getAttribute('href') || anchor.href || ''
|
||
: ''
|
||
const normalizedURL = normalizeInlineCitationUrl(rawHref)
|
||
if (!normalizedURL && !shouldStripDeepSeekOrphanAnchor(anchor)) {
|
||
continue
|
||
}
|
||
|
||
const source = inlineCitationLookup.get(normalizedURL) ?? null
|
||
const citationSource = citationSourceLookup.get(normalizedURL) ?? null
|
||
if (!source && !citationSource) {
|
||
if (shouldStripDeepSeekOrphanAnchor(anchor)) {
|
||
anchor.remove()
|
||
} else {
|
||
anchor.replaceWith(document.createTextNode(anchor.textContent ?? ''))
|
||
}
|
||
continue
|
||
}
|
||
|
||
anchor.setAttribute('href', normalizedURL)
|
||
anchor.setAttribute('target', '_blank')
|
||
anchor.setAttribute('rel', 'noreferrer')
|
||
anchor.setAttribute('class', 'tracking-inline-citation tracking-inline-citation--content')
|
||
if (citationSource) {
|
||
anchor.setAttribute('data-citation-index', String(citationSource.sourceGroupIndex))
|
||
} else {
|
||
anchor.removeAttribute('data-citation-index')
|
||
}
|
||
|
||
const title = citationSource
|
||
? resolveCitationTitle(citationSource.citation)
|
||
: source?.title?.trim() || ''
|
||
if (title) {
|
||
anchor.setAttribute('title', title)
|
||
} else {
|
||
anchor.removeAttribute('title')
|
||
}
|
||
|
||
const displayLabel =
|
||
normalizeDeepSeekCitationLabel(anchor.textContent) ??
|
||
getOrCreateDeepSeekFallbackCitationLabel(normalizedURL, fallbackLabels, fallbackState)
|
||
anchor.textContent = displayLabel
|
||
}
|
||
|
||
return root.innerHTML.trim() || answerText
|
||
}
|
||
|
||
function formatAnswerContent(platform: MonitoringQuestionDetailPlatform | null): string | null {
|
||
const rawAnswer = String(platform?.answer_text ?? '').trim()
|
||
if (!rawAnswer) {
|
||
return null
|
||
}
|
||
|
||
if (
|
||
isDeepSeekInlineCitationPlatform(platform?.ai_platform_id) &&
|
||
looksLikeHtmlAnswer(rawAnswer)
|
||
) {
|
||
return renderDeepSeekAnswerContent(rawAnswer, platform)
|
||
}
|
||
|
||
if (looksLikeHtmlAnswer(rawAnswer)) {
|
||
return rawAnswer
|
||
}
|
||
|
||
const sanitized = sanitizeAnswerBody(rawAnswer)
|
||
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 collectDeepSeekInlineCitationOccurrences(
|
||
answerText: string,
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): Array<{
|
||
url: string
|
||
label: string | null
|
||
siteName: string | null
|
||
citationCount: number
|
||
}> {
|
||
const root = parseHtmlFragment(answerText)
|
||
if (!root) {
|
||
return []
|
||
}
|
||
|
||
const sourceLookup = buildDeepSeekAnswerLinkSourceLookup(platform)
|
||
|
||
const counts = new Map<
|
||
string,
|
||
{ label: string | null; siteName: string | null; citationCount: number }
|
||
>()
|
||
for (const anchor of Array.from(root.querySelectorAll<HTMLAnchorElement>('a[href]'))) {
|
||
const normalizedURL = normalizeInlineCitationUrl(
|
||
anchor.getAttribute('href') || anchor.href || '',
|
||
)
|
||
if (!normalizedURL || !shouldCountDeepSeekAnswerLink(anchor)) {
|
||
continue
|
||
}
|
||
|
||
const source = sourceLookup.get(normalizedURL) ?? null
|
||
const existing = counts.get(normalizedURL)
|
||
if (existing) {
|
||
// One row per URL, but every visible answer occurrence contributes to the count.
|
||
existing.citationCount += 1
|
||
continue
|
||
}
|
||
|
||
counts.set(normalizedURL, {
|
||
label: source?.title || anchor.getAttribute('title')?.trim() || null,
|
||
siteName: source?.siteName || null,
|
||
citationCount: 1,
|
||
})
|
||
}
|
||
|
||
return Array.from(counts.entries()).map(([url, value]) => ({
|
||
url,
|
||
label: value.label,
|
||
siteName: value.siteName,
|
||
citationCount: value.citationCount,
|
||
}))
|
||
}
|
||
|
||
function analyzeInlineContentCitations(
|
||
platform: MonitoringQuestionDetailPlatform | null,
|
||
): TrackingAnalyzedContentCitation[] {
|
||
const answerText = String(platform?.answer_text ?? '')
|
||
if (!answerText) {
|
||
return []
|
||
}
|
||
|
||
if (isDeepSeekInlineCitationPlatform(platform?.ai_platform_id)) {
|
||
const occurrences = collectDeepSeekInlineCitationOccurrences(answerText, platform)
|
||
if (!occurrences.length) {
|
||
return []
|
||
}
|
||
|
||
const totalCitationCount = occurrences.reduce((sum, item) => sum + item.citationCount, 0)
|
||
if (!totalCitationCount) {
|
||
return []
|
||
}
|
||
|
||
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) => ({
|
||
key: `deepseek-${item.url}`,
|
||
contentName: item.label || item.url,
|
||
channelName: item.siteName || deriveCitationChannelName(item.url),
|
||
citationCount: item.citationCount,
|
||
citationRate: item.citationCount / totalCitationCount,
|
||
citedURL: item.url,
|
||
}))
|
||
}
|
||
|
||
if (isKimiInlineCitationPlatform(platform?.ai_platform_id)) {
|
||
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,
|
||
}
|
||
})
|
||
}
|
||
|
||
if (!isLinkedInlineCitationPlatform(platform?.ai_platform_id)) {
|
||
return []
|
||
}
|
||
|
||
const sourceGroupIndexes = collectInlineCitationIndexes(answerText)
|
||
if (!sourceGroupIndexes.length) {
|
||
return []
|
||
}
|
||
|
||
const citationCounts = new Map<number, number>()
|
||
for (const sourceGroupIndex of sourceGroupIndexes) {
|
||
citationCounts.set(sourceGroupIndex, (citationCounts.get(sourceGroupIndex) ?? 0) + 1)
|
||
}
|
||
|
||
const totalCitationCount = Array.from(citationCounts.values()).reduce(
|
||
(sum, count) => sum + count,
|
||
0,
|
||
)
|
||
if (!totalCitationCount) {
|
||
return []
|
||
}
|
||
|
||
return Array.from(citationCounts.entries())
|
||
.sort((left, right) => {
|
||
if (right[1] !== left[1]) {
|
||
return right[1] - left[1]
|
||
}
|
||
return left[0] - right[0]
|
||
})
|
||
.flatMap(([sourceGroupIndex, citationCount]) => {
|
||
const citation = platform?.citations?.[sourceGroupIndex - 1] ?? null
|
||
const citedURL = citation?.cited_url?.trim() ?? ''
|
||
if (!citedURL) {
|
||
return []
|
||
}
|
||
|
||
return [
|
||
{
|
||
key: `${sourceGroupIndex}-${citedURL}`,
|
||
contentName: citation ? resolveCitationTitle(citation) : `引用内容 #${sourceGroupIndex}`,
|
||
channelName: citation?.site_name ?? '--',
|
||
citationCount,
|
||
citationRate: citationCount / totalCitationCount,
|
||
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 {
|
||
const raw = Array.isArray(value) ? value[0] : value
|
||
return String(raw ?? '').trim()
|
||
}
|
||
|
||
function normalizeTrackingBusinessDate(value: unknown): string | null {
|
||
const normalized = normalizeQueryValue(value)
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
const parsed = dayjs(normalized)
|
||
if (!parsed.isValid() || parsed.format('YYYY-MM-DD') !== normalized) {
|
||
return null
|
||
}
|
||
|
||
const today = dayjs().startOf('day')
|
||
const earliestAllowed = today.subtract(trackingMaxHistoryDays - 1, 'day')
|
||
if (parsed.isAfter(today, 'day') || parsed.isBefore(earliestAllowed, 'day')) {
|
||
return null
|
||
}
|
||
return normalized
|
||
}
|
||
|
||
function parsePositiveNumber(value: unknown): number | null {
|
||
const raw = Array.isArray(value) ? value[0] : value
|
||
const parsed = Number(raw)
|
||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||
return null
|
||
}
|
||
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: '38%',
|
||
},
|
||
{
|
||
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: '16%',
|
||
},
|
||
{
|
||
title: t('tracking.columns.share'),
|
||
key: 'citationRate',
|
||
dataIndex: 'citationRate',
|
||
align: 'center' as const,
|
||
width: '16%',
|
||
},
|
||
{ title: t('common.actions'), key: 'actions', align: 'center' as const, width: '10%' },
|
||
])
|
||
</script>
|
||
|
||
<template>
|
||
<section class="tracking-question-page">
|
||
<div class="tracking-question-page__hero">
|
||
<div class="tracking-question-page__header-top">
|
||
<button type="button" class="tracking-question-page__back" @click="goBack">
|
||
<ArrowLeftOutlined />
|
||
<span>{{ t('tracking.backToQuestions') }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="tracking-question-page__copy">
|
||
<p class="tracking-question-page__eyebrow">{{ t('tracking.questionDetailEyebrow') }}</p>
|
||
<h1>{{ pageTitle }}</h1>
|
||
<p class="tracking-question-page__meta">
|
||
{{ t('tracking.questionDetailCollectedAt', { time: collectedAtDisplay }) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<a-alert
|
||
v-if="detailQuery.error.value"
|
||
type="error"
|
||
show-icon
|
||
:message="formatError(detailQuery.error.value)"
|
||
class="tracking-question-page__alert"
|
||
/>
|
||
|
||
<a-spin :spinning="detailQuery.isLoading.value">
|
||
<div class="tracking-question-page__content">
|
||
<a-alert
|
||
v-if="shouldShowDetailUnavailableNotice"
|
||
:type="detailUnavailableNoticeType"
|
||
show-icon
|
||
:message="detailUnavailableNoticeTitle"
|
||
:description="detailUnavailableNoticeDescription"
|
||
class="tracking-question-page__alert"
|
||
/>
|
||
|
||
<div v-if="!shouldShowDetailUnavailableNotice" class="tracking-question-page__tabs-wrapper">
|
||
<a-tabs v-model:activeKey="activePlatformId" size="large" class="tracking-question-tabs">
|
||
<a-tab-pane v-for="platform in detailPlatforms" :key="platform.ai_platform_id">
|
||
<template #tab>
|
||
<span :class="platform.sample_status !== 'sampled' ? 'is-muted' : ''">
|
||
{{ platform.platform_name }}
|
||
</span>
|
||
</template>
|
||
</a-tab-pane>
|
||
</a-tabs>
|
||
</div>
|
||
|
||
<div v-if="!shouldShowDetailUnavailableNotice" class="tracking-question-grid">
|
||
<section class="tracking-question-card tracking-question-card--answer">
|
||
<div class="tracking-question-card__header">
|
||
<div class="tracking-question-card__section-title">
|
||
<span class="tracking-question-card__accent" />
|
||
<span>{{ t('tracking.answerContent') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tracking-question-card__body">
|
||
<div class="tracking-question-answer__headline">
|
||
<h2>{{ activePlatform?.platform_name ?? t('common.noData') }}</h2>
|
||
<a-tag :color="statusTagColor(activePlatform?.sample_status ?? 'pending')">
|
||
{{ statusLabel(activePlatform?.sample_status ?? 'pending') }}
|
||
</a-tag>
|
||
</div>
|
||
|
||
<div class="tracking-question-answer__content custom-scrollbar">
|
||
<MarkdownPreview
|
||
v-if="renderedAnswerContent"
|
||
:content="renderedAnswerContent"
|
||
@click="handleAnswerContentClick"
|
||
/>
|
||
<a-empty v-else :description="t('tracking.noAnswer')" />
|
||
</div>
|
||
|
||
<div class="tracking-question-answer__meta">
|
||
<span>
|
||
{{
|
||
activePlatform?.sampled_at ? formatDateTime(activePlatform.sampled_at) : '--'
|
||
}}
|
||
</span>
|
||
<span>{{ resolveAnswerProviderModel(activePlatform) }}</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="tracking-question-card">
|
||
<div class="tracking-question-card__header">
|
||
<div class="tracking-question-card__section-title">
|
||
<span class="tracking-question-card__accent" />
|
||
<span>{{ t('tracking.citationSourcesTitle') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
ref="citationSourcesScrollRef"
|
||
class="tracking-question-card__scroll-area custom-scrollbar"
|
||
>
|
||
<div v-if="activePlatform?.citations?.length" class="tracking-question-source-list">
|
||
<div
|
||
v-for="(citation, index) in activePlatform.citations"
|
||
:key="citation.cited_url"
|
||
role="link"
|
||
tabindex="0"
|
||
class="tracking-question-source-card"
|
||
:class="{ 'is-linked': highlightedCitationIndex === index + 1 }"
|
||
:data-citation-index="index + 1"
|
||
@click="openExternalURL(citation.cited_url)"
|
||
@keydown.enter="openExternalURL(citation.cited_url)"
|
||
>
|
||
<div class="tracking-question-source-card__headline">
|
||
<div class="tracking-question-source-card__indexes">
|
||
<span
|
||
v-for="label in resolveCitationIndexDisplayLabels(index, activePlatform)"
|
||
:key="`${citation.cited_url}-${label}`"
|
||
class="tracking-question-source-card__reference-badge"
|
||
>
|
||
{{ label }}
|
||
</span>
|
||
<span
|
||
v-if="shouldShowCitationSourceListIndex(index, activePlatform)"
|
||
class="tracking-question-source-card__index"
|
||
:class="{
|
||
'tracking-question-source-card__index--secondary':
|
||
resolveCitationIndexDisplayLabels(index, activePlatform).length > 0,
|
||
}"
|
||
>
|
||
[{{ index + 1 }}]
|
||
</span>
|
||
</div>
|
||
<strong>{{ citation.site_name }}</strong>
|
||
<span
|
||
v-if="resolveCitationTitle(citation)"
|
||
class="tracking-question-source-card__divider"
|
||
>
|
||
·
|
||
</span>
|
||
<span
|
||
class="tracking-question-source-card__title"
|
||
:title="resolveCitationTitle(citation)"
|
||
>
|
||
{{ resolveCitationTitle(citation) }}
|
||
</span>
|
||
<a-tooltip :title="t('tracking.imitationAction')">
|
||
<button
|
||
type="button"
|
||
class="tracking-question-imitation-trigger tracking-question-imitation-trigger--source"
|
||
@click.stop="
|
||
openImitationCreate(citation.cited_url, resolveCitationTitle(citation))
|
||
"
|
||
>
|
||
<CopyOutlined />
|
||
</button>
|
||
</a-tooltip>
|
||
<a-tag
|
||
v-if="
|
||
citation.article_id &&
|
||
supportsContentCitationDisplay(activePlatform?.ai_platform_id)
|
||
"
|
||
color="blue"
|
||
class="tracking-question-source-card__badge"
|
||
>
|
||
{{ t('tracking.contentCitationBadge') }}
|
||
</a-tag>
|
||
</div>
|
||
<div class="tracking-question-source-card__link">
|
||
{{ citation.cited_url }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<a-empty v-else :description="t('tracking.noCitations')" />
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Citation Analysis -->
|
||
<section
|
||
:class="[
|
||
'tracking-question-card',
|
||
!shouldShowInlineContentCitations ? 'tracking-question-card--span-2' : '',
|
||
]"
|
||
>
|
||
<div class="tracking-question-card__header">
|
||
<div class="tracking-question-card__section-title">
|
||
<span class="tracking-question-card__accent" />
|
||
<span>{{ t('tracking.citationAnalysisTitle') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="tracking-question-card__scroll-area custom-scrollbar tracking-question-table-wrap"
|
||
>
|
||
<a-table
|
||
v-if="activeCitationAnalysis.length"
|
||
row-key="site_key"
|
||
class="modern-table"
|
||
:columns="citationAnalysisColumns"
|
||
:data-source="activeCitationAnalysis"
|
||
:pagination="false"
|
||
size="middle"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'sourcePlatform'">
|
||
<div class="cell-primary-stack">
|
||
<strong class="table-text" :title="record.site_name">
|
||
{{ record.site_name }}
|
||
</strong>
|
||
<small
|
||
v-if="
|
||
record.content_citation_count > 0 &&
|
||
supportsContentCitationDisplay(record.ai_platform_id)
|
||
"
|
||
class="meta-subtext"
|
||
>
|
||
{{
|
||
t('tracking.contentCitationMeta', {
|
||
count: record.content_citation_count,
|
||
})
|
||
}}
|
||
</small>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'domain'">
|
||
<span class="tracking-question-table__secondary">
|
||
{{ record.site_domain || record.site_key }}
|
||
</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'citationCount'">
|
||
<span class="metric-value">{{ record.citation_count }}</span>
|
||
</template>
|
||
<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')"
|
||
class="tracking-question-empty"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Content Citations -->
|
||
<section v-if="shouldShowInlineContentCitations" class="tracking-question-card">
|
||
<div class="tracking-question-card__header">
|
||
<div class="tracking-question-card__section-title">
|
||
<span class="tracking-question-card__accent" />
|
||
<span>{{ t('tracking.contentCitationsTitle') }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="tracking-question-card__scroll-area custom-scrollbar tracking-question-table-wrap"
|
||
>
|
||
<a-table
|
||
row-key="key"
|
||
class="modern-table"
|
||
:columns="contentCitationColumns"
|
||
:data-source="activeInlineContentCitations"
|
||
:pagination="false"
|
||
size="middle"
|
||
>
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'contentName'">
|
||
<div class="cell-primary-stack">
|
||
<a
|
||
v-if="record.citedURL"
|
||
:href="record.citedURL"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
:title="record.contentName"
|
||
class="table-link"
|
||
>
|
||
{{ record.contentName }}
|
||
</a>
|
||
<strong v-else class="table-text" :title="record.contentName">
|
||
{{ record.contentName }}
|
||
</strong>
|
||
</div>
|
||
</template>
|
||
<template v-else-if="column.key === 'channelName'">
|
||
<span class="tracking-question-table__secondary" :title="record.channelName">
|
||
{{ record.channelName }}
|
||
</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'citationCount'">
|
||
<span class="metric-value">{{ record.citationCount }}</span>
|
||
</template>
|
||
<template v-else-if="column.key === 'citationRate'">
|
||
<strong class="metric-highlight">
|
||
{{ formatPercent(record.citationRate) }}
|
||
</strong>
|
||
</template>
|
||
<template v-else-if="column.key === 'actions'">
|
||
<a-tooltip v-if="record.citedURL" :title="t('tracking.imitationAction')">
|
||
<button
|
||
type="button"
|
||
class="tracking-question-imitation-trigger tracking-question-imitation-trigger--table-action"
|
||
@click.stop="openImitationCreate(record.citedURL, record.contentName)"
|
||
>
|
||
<CopyOutlined />
|
||
</button>
|
||
</a-tooltip>
|
||
<span v-else class="tracking-question-table__empty-action">--</span>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</a-spin>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.tracking-question-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.tracking-question-page__content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
}
|
||
|
||
.tracking-question-page__hero {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
padding: 24px 32px;
|
||
background: #ffffff;
|
||
border: 1px solid #e8edf5;
|
||
border-radius: 20px;
|
||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.02);
|
||
}
|
||
|
||
.tracking-question-page__header-top {
|
||
display: flex;
|
||
}
|
||
|
||
.tracking-question-page__back {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
height: 32px;
|
||
padding: 0 12px 0 8px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: #64748b;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
margin-left: -8px;
|
||
}
|
||
|
||
.tracking-question-page__back:hover {
|
||
background: #e2e8f0;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.tracking-question-page__copy {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.tracking-question-page__eyebrow {
|
||
margin: 0;
|
||
color: #2563eb;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.tracking-question-page__copy h1 {
|
||
margin: 0;
|
||
color: #0f172a;
|
||
font-size: 32px;
|
||
font-weight: 800;
|
||
line-height: 1.25;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.tracking-question-page__meta {
|
||
margin: 8px 0 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
background: #f1f5f9;
|
||
border-radius: 6px;
|
||
color: #475569;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
width: fit-content;
|
||
}
|
||
|
||
.tracking-question-page__alert {
|
||
border-radius: 16px;
|
||
}
|
||
|
||
.tracking-question-page__tabs-wrapper {
|
||
background: #ffffff;
|
||
padding: 4px 24px 0;
|
||
border-radius: 16px;
|
||
border: 1px solid #e8edf5;
|
||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.02);
|
||
}
|
||
|
||
.tracking-question-tabs :deep(.ant-tabs-nav) {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.tracking-question-tabs .is-muted {
|
||
opacity: 0.65;
|
||
}
|
||
|
||
.tracking-question-grid {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr);
|
||
grid-auto-rows: 800px;
|
||
gap: 20px;
|
||
}
|
||
|
||
.tracking-question-card {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 24px 28px;
|
||
border: 1px solid #e8edf5;
|
||
border-radius: 20px;
|
||
background: #ffffff;
|
||
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.03);
|
||
}
|
||
|
||
.tracking-question-card--answer {
|
||
/* removed min-height, grid-auto-rows handles this universally */
|
||
}
|
||
|
||
.tracking-question-card--span-2 {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.tracking-question-card__header {
|
||
margin-bottom: 20px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tracking-question-card__header--split {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
}
|
||
|
||
.tracking-question-card__section-title {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
color: #111827;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.tracking-question-card__accent {
|
||
display: inline-block;
|
||
width: 6px;
|
||
height: 28px;
|
||
border-radius: 999px;
|
||
background: #2563eb;
|
||
}
|
||
|
||
.tracking-question-card__hint {
|
||
margin: 0;
|
||
max-width: 420px;
|
||
color: #94a3b8;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
text-align: right;
|
||
}
|
||
|
||
.tracking-question-card__body {
|
||
display: flex;
|
||
flex: 1;
|
||
min-height: 0;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tracking-question-card__scroll-area {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
margin-right: -12px;
|
||
padding-right: 12px;
|
||
}
|
||
|
||
.tracking-question-table-wrap {
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.tracking-question-empty {
|
||
margin-top: 40px;
|
||
}
|
||
|
||
.tracking-question-answer__headline {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tracking-question-answer__content {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
margin-right: -12px;
|
||
padding-right: 12px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background: #e2e8f0;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||
background: #cbd5e1;
|
||
}
|
||
|
||
.tracking-question-answer__headline h2 {
|
||
margin: 0;
|
||
font-size: 22px;
|
||
}
|
||
|
||
.tracking-question-answer__meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 18px;
|
||
margin-top: auto;
|
||
padding-top: 18px;
|
||
color: #9ca3af;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.tracking-question-source-list,
|
||
.tracking-question-content-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
|
||
.tracking-question-source-card,
|
||
.tracking-question-content-card {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
padding: 12px 16px;
|
||
border: 1px solid #edf2f7;
|
||
border-radius: 10px;
|
||
background: #f8fbff;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
text-decoration: none;
|
||
}
|
||
|
||
.tracking-question-source-card:hover,
|
||
.tracking-question-content-card:hover {
|
||
border-color: #dbeafe;
|
||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.05);
|
||
}
|
||
|
||
.tracking-question-source-card {
|
||
padding-right: 58px;
|
||
}
|
||
|
||
.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 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.tracking-question-source-card__indexes {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.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__index--secondary {
|
||
background: #f8fafc;
|
||
color: #64748b;
|
||
border: 1px solid #e2e8f0;
|
||
}
|
||
|
||
.tracking-question-source-card__reference-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 34px;
|
||
height: 24px;
|
||
padding: 0 8px;
|
||
border-radius: 999px;
|
||
border: 1px solid #bfdbfe;
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.tracking-question-source-card__headline strong {
|
||
display: block;
|
||
color: #2563eb;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tracking-question-source-card__divider {
|
||
color: #cbd5e1;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tracking-question-source-card__title {
|
||
color: #475569;
|
||
font-size: 14px;
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.tracking-question-source-card__badge {
|
||
margin: 0;
|
||
margin-left: auto;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.tracking-question-imitation-trigger {
|
||
display: inline-flex;
|
||
width: 28px;
|
||
height: 28px;
|
||
flex-shrink: 0;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid #dbeafe;
|
||
border-radius: 8px;
|
||
background: #ffffff;
|
||
color: #2563eb;
|
||
cursor: pointer;
|
||
opacity: 0;
|
||
transform: translateY(1px);
|
||
transition:
|
||
opacity 0.18s ease,
|
||
transform 0.18s ease,
|
||
border-color 0.18s ease,
|
||
background-color 0.18s ease;
|
||
}
|
||
|
||
.tracking-question-imitation-trigger:hover {
|
||
border-color: #93c5fd;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.tracking-question-imitation-trigger--source {
|
||
position: absolute;
|
||
top: 50%;
|
||
right: 16px;
|
||
transform: translateY(calc(-50% + 1px));
|
||
}
|
||
|
||
.tracking-question-source-card:hover .tracking-question-imitation-trigger,
|
||
.tracking-question-source-card:focus-within .tracking-question-imitation-trigger {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
|
||
.tracking-question-source-card:hover .tracking-question-imitation-trigger--source,
|
||
.tracking-question-source-card:focus-within .tracking-question-imitation-trigger--source {
|
||
opacity: 1;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.tracking-question-imitation-trigger--table-action {
|
||
margin: 0 auto;
|
||
opacity: 1;
|
||
transform: none;
|
||
}
|
||
|
||
.tracking-question-table__empty-action {
|
||
color: #cbd5e1;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tracking-question-source-card__link {
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
width: 100%;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.tracking-question-content-card__copy a {
|
||
color: #2563eb;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.tracking-question-answer__content :deep(.tracking-inline-citation) {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 28px;
|
||
height: 22px;
|
||
margin-left: 6px;
|
||
padding: 0 8px;
|
||
border: 1px solid #bfdbfe;
|
||
border-radius: 999px;
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
vertical-align: baseline;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
transition: all 0.18s ease;
|
||
}
|
||
|
||
.tracking-question-answer__content :deep(.tracking-inline-citation:hover) {
|
||
border-color: #93c5fd;
|
||
background: #dbeafe;
|
||
}
|
||
|
||
.tracking-question-answer__content :deep(.tracking-inline-citation--muted) {
|
||
cursor: default;
|
||
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 {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
word-break: break-all;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.meta-subtext {
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.metric-value {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
color: #475569;
|
||
font-weight: 600;
|
||
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 {
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.tracking-task-debug-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
.tracking-task-debug-card {
|
||
padding: 20px 22px;
|
||
border: 1px solid #edf2f7;
|
||
border-radius: 20px;
|
||
background: linear-gradient(180deg, #fbfdff 0%, #f5f9ff 100%);
|
||
}
|
||
|
||
.tracking-task-debug-card__header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin-bottom: 18px;
|
||
}
|
||
|
||
.tracking-task-debug-card__headline {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.tracking-task-debug-card__headline strong {
|
||
color: #111827;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.tracking-task-debug-card__headline small {
|
||
color: #94a3b8;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.tracking-task-debug-card__tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
}
|
||
|
||
.tracking-task-debug-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 14px;
|
||
}
|
||
|
||
.tracking-task-debug-grid__item {
|
||
display: flex;
|
||
min-width: 0;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 14px 16px;
|
||
border-radius: 16px;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.tracking-task-debug-grid__item span {
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tracking-task-debug-grid__item strong {
|
||
color: #1f2937;
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.tracking-task-debug-notes {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.tracking-task-debug-notes__item {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 14px 16px;
|
||
border-radius: 16px;
|
||
background: #ffffff;
|
||
}
|
||
|
||
.tracking-task-debug-notes__item span {
|
||
color: #94a3b8;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tracking-task-debug-notes__item strong {
|
||
color: #1f2937;
|
||
line-height: 1.6;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.tracking-task-debug-notes__item.is-error {
|
||
background: #fff7f7;
|
||
}
|
||
|
||
.tracking-task-debug-notes__item.is-error strong {
|
||
color: #b91c1c;
|
||
}
|
||
|
||
@media (max-width: 1080px) {
|
||
.tracking-question-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.tracking-task-debug-grid,
|
||
.tracking-task-debug-notes {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
}
|
||
|
||
@media (max-width: 720px) {
|
||
.tracking-question-page__hero {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tracking-question-page__copy h1 {
|
||
font-size: 26px;
|
||
}
|
||
|
||
.tracking-question-card {
|
||
padding: 22px 18px;
|
||
}
|
||
|
||
.tracking-question-card__header--split,
|
||
.tracking-task-debug-card__header {
|
||
flex-direction: column;
|
||
}
|
||
|
||
.tracking-question-card__hint,
|
||
.tracking-task-debug-card__tags {
|
||
max-width: none;
|
||
text-align: left;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.tracking-question-table__head,
|
||
.tracking-question-table__row {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.tracking-question-source-card,
|
||
.tracking-question-content-card {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.tracking-question-source-card__meta {
|
||
align-items: flex-start;
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.tracking-task-debug-grid,
|
||
.tracking-task-debug-notes {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
</style>
|