Files
geo/apps/admin-web/src/views/TrackingQuestionDetailView.vue
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- 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>
2026-05-01 20:39:09 +08:00

2222 lines
61 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>