Files
geo/apps/admin-web/src/views/TrackingQuestionDetailView.vue
T

2487 lines
69 KiB
Vue
Raw Normal View History

2026-04-12 09:56:18 +08:00
<script setup lang="ts">
import { ArrowLeftOutlined, CopyOutlined } from '@ant-design/icons-vue'
2026-04-12 09:56:18 +08:00
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'
2026-05-20 15:37:25 +08:00
import { useCompanyStore } from '@/stores/company'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
2026-05-20 15:37:25 +08:00
const companyStore = useCompanyStore()
const trackingMaxHistoryDays = 30
const trackingToday = dayjs().format('YYYY-MM-DD')
2026-05-20 15:37:25 +08:00
const routeBrandId = computed(() => parsePositiveNumber(route.params.brandId))
const brandId = computed(() => companyStore.currentBrandId ?? routeBrandId.value)
const questionId = computed(() => parsePositiveNumber(route.params.questionId))
const questionHash = computed(
() =>
normalizeHistoryStateValue(history.state?.tracking_question_hash) ||
normalizeQueryValue(route.query.question_hash),
)
const questionTitleFallback = computed(
() =>
normalizeHistoryStateValue(history.state?.tracking_question_text) ||
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,
)
2026-04-12 09:56:18 +08:00
const dateFrom = computed(() => {
return normalizeQueryValue(route.query.date_from) || businessDate.value
})
2026-04-12 09:56:18 +08:00
const dateTo = computed(() => {
return normalizeQueryValue(route.query.date_to) || businessDate.value
})
2026-04-12 09:56:18 +08:00
const detailQuery = useQuery({
queryKey: computed(() => [
'tracking',
'question-detail-page',
2026-04-12 09:56:18 +08:00
brandId.value,
questionId.value,
questionHash.value,
requestedPlatformId.value,
2026-04-12 09:56:18 +08:00
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,
2026-04-12 09:56:18 +08:00
}),
})
2026-04-12 09:56:18 +08:00
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
2026-04-12 09:56:18 +08:00
watch(
detailPlatforms,
2026-04-12 09:56:18 +08:00
(platforms) => {
if (!platforms?.length) {
activePlatformId.value = ''
return
2026-04-12 09:56:18 +08:00
}
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]
2026-04-12 09:56:18 +08:00
if (!platforms.some((item) => item.ai_platform_id === activePlatformId.value)) {
activePlatformId.value = nextPlatform.ai_platform_id
2026-04-12 09:56:18 +08:00
}
},
{ immediate: true },
)
2026-04-12 09:56:18 +08:00
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,
)
2026-04-12 09:56:18 +08:00
const activeCitationAnalysis = computed<MonitoringQuestionCitationAnalysisItem[]>(() => {
const items = detailCitationAnalysis.value
2026-04-12 09:56:18 +08:00
if (!activePlatformId.value) {
return items
2026-04-12 09:56:18 +08:00
}
return items.filter((item) => item.ai_platform_id === activePlatformId.value)
})
2026-04-12 09:56:18 +08:00
const latestSampledAt = computed<string | null>(() => {
const platforms = detailPlatforms.value
2026-04-12 09:56:18 +08:00
const sampledAtValues = platforms
.map((item) => item.sampled_at)
.filter((value): value is string => Boolean(value))
2026-04-12 09:56:18 +08:00
if (!sampledAtValues.length) {
return null
2026-04-12 09:56:18 +08:00
}
return sampledAtValues.reduce((latest, current) => {
if (!latest) {
return current
2026-04-12 09:56:18 +08:00
}
return dayjs(current).isAfter(dayjs(latest)) ? current : latest
}, sampledAtValues[0] ?? null)
})
2026-04-12 09:56:18 +08:00
const collectedAtDisplay = computed(() => {
const sampledAt = activePlatform.value?.sampled_at || latestSampledAt.value
return sampledAt ? formatDateTime(sampledAt) : '--'
})
2026-04-12 09:56:18 +08:00
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()
})
2026-04-12 09:56:18 +08:00
function goBack(): void {
void router.push({
name: 'tracking',
2026-04-12 09:56:18 +08:00
query: {
brand_id: brandId.value ? String(brandId.value) : undefined,
keyword_id: keywordId.value || undefined,
ai_platform_id: requestedPlatformId.value || undefined,
2026-04-12 09:56:18 +08:00
business_date: businessDate.value,
},
})
2026-04-12 09:56:18 +08:00
}
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,
},
})
}
2026-04-12 09:56:18 +08:00
function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
2026-04-12 09:56:18 +08:00
}
return `${(value * 100).toFixed(2)}%`
2026-04-12 09:56:18 +08:00
}
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'
2026-04-12 09:56:18 +08:00
default:
return 'default'
2026-04-12 09:56:18 +08:00
}
}
function statusLabel(status: string): string {
return t(`tracking.status.${status}`)
2026-04-12 09:56:18 +08:00
}
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() || '--'
}
2026-04-12 09:56:18 +08:00
function resolveCitationTitle(citation: MonitoringQuestionDetailCitation): string {
return citation.cited_title || citation.article_title || citation.cited_url
2026-04-12 09:56:18 +08:00
}
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 {
// Match both formats so the same renderer works across the migration:
// - "[citation:N]" — legacy yuanbao SSE protocol.
// - "[N]" — new hunyuan_t1 protocol; the desktop adapter now lowers
// `<div data-idx-list="1,2,10">` placeholders to bare "[N]" and also
// normalises any residual "[citation:N]" SSE frames to bare "[N]" so
// yuanbao answers render with the same chip-style superscript as
// DeepSeek.
return /\[(?:citation:\s*)?(\d+)\]/gi
}
function createYuanbaoCitationStripPattern(): RegExp {
// The pattern's `replace` step covers any `[N]` we successfully matched.
// Strip leftover legacy `[citation:...]` fragments only — we must not
// strip bare "[N]" tokens here, because formatAnswerContent runs strip
// *after* replacing matched markers, so any remaining "[N]" is incidental
// text that should stay untouched.
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 normalizeLooseMarkdownLine(line: string): string {
const headingNormalizedLine = line
.replace(/^[\u200b-\u200f\ufeff]+/, '')
.replace(/^([ \t]{0,12})(?:\\#|&#35;|&#x23;|)/i, '$1#')
const answerScopedHeading = headingNormalizedLine.replace(
/^([ \t]{0,12})#{1,2}(?!#)\s*(?=\S)/,
'$1### ',
)
return answerScopedHeading
.replace(/^([ \t]{0,12})(#{1,6})(?=\S)/, '$1$2 ')
.replace(/^([ \t]{0,12}#{1,6}\s+)#{1,6}\s*/, '$1')
.replace(/^([ \t]{0,12}#{1,6}\s+\d+[.)、])(?=\S)/, '$1 ')
.replace(/^(\s*)([-*+])(?=\S)/, '$1$2 ')
.replace(/^(\s*)(\d{1,3})([.)])(?=\S)/, '$1$2$3 ')
}
function countMarkdownTablePipes(line: string): number {
return (line.match(/\|/g) ?? []).length
}
function splitMarkdownTableCells(line: string): string[] {
return line
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((cell) => cell.trim())
}
function isMarkdownTableSeparatorLine(line: string): boolean {
const normalized = line.trim()
if (!normalized.includes('|')) {
return false
}
const cells = splitMarkdownTableCells(normalized)
return cells.length >= 2 && cells.every((cell) => /^:?-{3,}:?$/.test(cell))
}
function countMarkdownTableCells(line: string): number {
return splitMarkdownTableCells(line).length
}
function normalizeMarkdownTableLine(line: string, targetCellCount?: number): string {
let normalized = line.trim()
if (!normalized.startsWith('|')) {
normalized = `| ${normalized}`
}
if (!normalized.endsWith('|')) {
normalized = `${normalized} |`
}
if (targetCellCount && targetCellCount > 0) {
const cells = splitMarkdownTableCells(normalized)
if (isMarkdownTableSeparatorLine(normalized)) {
while (cells.length < targetCellCount) {
cells.push('--------')
}
} else {
while (cells.length < targetCellCount) {
cells.push('')
}
}
if (cells.length > targetCellCount) {
cells.length = targetCellCount
}
normalized = `| ${cells.join(' | ')} |`
}
return normalized.replace(/\s*\|\s*/g, ' | ').replace(/\s{2,}/g, ' ').trim()
}
function normalizeLooseMarkdownTables(content: string): string {
const detachedTableBlocks = content
.replace(/\|\|(?=\s*:?-{3,}:?\s*\|)/g, '|\n|')
.replace(
/(^|\n)(\s{0,3}#{1,6}\s+[^|\n]{1,80})\|(?=[^|\n]+\|[^\n]*\n\s*\|?\s*:?-{3,}:?\s*\|)/g,
'$1$2\n|',
)
const lines = detachedTableBlocks.split('\n').flatMap((line: string) => {
const separatorWithFirstRow = line
.trim()
.match(/^((?:\|\s*:?-{3,}:?\s*){2,}\|?)(\S.*)$/)
if (
!separatorWithFirstRow?.[1] ||
!separatorWithFirstRow[2] ||
/^\|*$/.test(separatorWithFirstRow[2].trim())
) {
return [line]
}
return [separatorWithFirstRow[1], separatorWithFirstRow[2]]
})
const normalizedLines: string[] = []
let inTable = false
let tableCellCount = 0
for (const line of lines) {
const previousIndex = normalizedLines.length - 1
if (isMarkdownTableSeparatorLine(line)) {
tableCellCount =
previousIndex >= 0
? Math.max(
countMarkdownTableCells(normalizedLines[previousIndex] ?? ''),
countMarkdownTableCells(line),
)
: countMarkdownTableCells(line)
if (
previousIndex >= 0 &&
countMarkdownTablePipes(normalizedLines[previousIndex] ?? '') >= 2
) {
normalizedLines[previousIndex] = normalizeMarkdownTableLine(
normalizedLines[previousIndex] ?? '',
tableCellCount,
)
}
normalizedLines.push(normalizeMarkdownTableLine(line, tableCellCount))
inTable = true
continue
}
if (inTable && countMarkdownTablePipes(line) >= 2) {
normalizedLines.push(normalizeMarkdownTableLine(line, tableCellCount))
continue
}
inTable = false
tableCellCount = 0
normalizedLines.push(line)
}
return normalizedLines.join('\n')
}
function normalizeAnswerMarkdown(content: string): string {
const headingMarkerNormalized = content
.replace(/(^|\n)[\u200b-\u200f\ufeff]*([ \t]{0,12})(?:\\#|&#35;|&#x23;|)/gi, '$1$2#')
.replace(/([^\n])(?=[\u200b-\u200f\ufeff]*[ \t]{0,12}(?:\\#|&#35;|&#x23;||#){1,6}\s*(?:\d{1,3}[.)、]|[一二三四五六七八九十]+[、..]|第[一二三四五六七八九十0-9]+))/gi, '$1\n\n')
const detachedHeadings = normalizeLooseMarkdownTables(headingMarkerNormalized)
.replace(
/([^\n])(?=[\u200b-\u200f\ufeff]*[ \t]{0,12}#{1,6}\s*(?:\d{1,3}[.)、]|[一二三四五六七八九十]+[、..]|第[一二三四五六七八九十0-9]+))/g,
'$1\n\n',
)
.replace(/([^\n])(?=#{2,6}\s*\S)/g, '$1\n\n')
const lines = detachedHeadings.split('\n')
let inFence = false
const normalizedLines = lines.map((line) => {
if (/^\s*(?:```|~~~)/.test(line)) {
inFence = !inFence
return line
}
if (inFence) {
return line
}
return normalizeLooseMarkdownLine(line)
})
return normalizedLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
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
const normalizedMarkdown = withInlineMarkers
.replace(createQwenSourceGroupStripPattern(), '')
.replace(createYuanbaoCitationStripPattern(), '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim()
return normalizeAnswerMarkdown(normalizedMarkdown)
}
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)
}
2026-04-12 09:56:18 +08:00
function normalizeQueryValue(value: unknown): string {
const raw = Array.isArray(value) ? value[0] : value
return String(raw ?? '').trim()
2026-04-12 09:56:18 +08:00
}
function normalizeHistoryStateValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
2026-04-12 09:56:18 +08:00
function normalizeTrackingBusinessDate(value: unknown): string | null {
const normalized = normalizeQueryValue(value)
2026-04-12 09:56:18 +08:00
if (!normalized) {
return null
2026-04-12 09:56:18 +08:00
}
const parsed = dayjs(normalized)
if (!parsed.isValid() || parsed.format('YYYY-MM-DD') !== normalized) {
return null
2026-04-12 09:56:18 +08:00
}
const today = dayjs().startOf('day')
const earliestAllowed = today.subtract(trackingMaxHistoryDays - 1, 'day')
if (parsed.isAfter(today, 'day') || parsed.isBefore(earliestAllowed, 'day')) {
return null
2026-04-12 09:56:18 +08:00
}
return normalized
2026-04-12 09:56:18 +08:00
}
function parsePositiveNumber(value: unknown): number | null {
const raw = Array.isArray(value) ? value[0] : value
const parsed = Number(raw)
2026-04-12 09:56:18 +08:00
if (!Number.isFinite(parsed) || parsed <= 0) {
return null
2026-04-12 09:56:18 +08:00
}
return parsed
2026-04-12 09:56:18 +08:00
}
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%' },
])
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
</button>
</div>
<div class="tracking-question-page__copy">
<p class="tracking-question-page__eyebrow">{{ t('tracking.questionDetailEyebrow') }}</p>
2026-04-12 09:56:18 +08:00
<h1>{{ pageTitle }}</h1>
<p class="tracking-question-page__meta">
{{ t('tracking.questionDetailCollectedAt', { time: collectedAtDisplay }) }}
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
<div class="tracking-question-answer__meta">
<span>
{{
activePlatform?.sampled_at ? formatDateTime(activePlatform.sampled_at) : '--'
}}
</span>
<span>{{ resolveAnswerProviderModel(activePlatform) }}</span>
</div>
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
</div>
</div>
<a-empty v-else :description="t('tracking.noCitations')" />
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
</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>
2026-04-12 09:56:18 +08:00
</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;
}
2026-04-12 09:56:18 +08:00
.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;
}
2026-04-12 09:56:18 +08:00
.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;
}
.tracking-question-answer__content :deep(.markdown-preview) {
color: #111827;
font-size: 15px;
line-height: 1.85;
overflow-wrap: anywhere;
}
.tracking-question-answer__content :deep(.markdown-preview h1),
.tracking-question-answer__content :deep(.markdown-preview h2),
.tracking-question-answer__content :deep(.markdown-preview h3),
.tracking-question-answer__content :deep(.markdown-preview h4),
.tracking-question-answer__content :deep(.markdown-preview h5),
.tracking-question-answer__content :deep(.markdown-preview h6) {
margin: 20px 0 10px;
color: #0f172a;
line-height: 1.35;
letter-spacing: 0;
}
.tracking-question-answer__content :deep(.markdown-preview h1) {
font-size: 24px;
}
.tracking-question-answer__content :deep(.markdown-preview h2) {
font-size: 21px;
}
.tracking-question-answer__content :deep(.markdown-preview h3) {
font-size: 18px;
}
.tracking-question-answer__content :deep(.markdown-preview h4),
.tracking-question-answer__content :deep(.markdown-preview h5),
.tracking-question-answer__content :deep(.markdown-preview h6) {
font-size: 16px;
}
.tracking-question-answer__content :deep(.markdown-preview p),
.tracking-question-answer__content :deep(.markdown-preview ul),
.tracking-question-answer__content :deep(.markdown-preview ol),
.tracking-question-answer__content :deep(.markdown-preview blockquote),
.tracking-question-answer__content :deep(.markdown-preview table) {
margin: 0 0 14px;
}
.tracking-question-answer__content :deep(.markdown-preview ul),
.tracking-question-answer__content :deep(.markdown-preview ol) {
padding-left: 22px;
}
.tracking-question-answer__content :deep(.markdown-preview li + li) {
margin-top: 6px;
}
.tracking-question-answer__content :deep(.markdown-preview strong) {
color: #0f172a;
font-weight: 700;
}
.tracking-question-answer__content :deep(.markdown-preview hr) {
margin: 22px 0;
}
.tracking-question-answer__content :deep(.markdown-preview table) {
display: block;
max-width: 100%;
overflow-x: auto;
border-radius: 8px;
}
2026-04-12 09:56:18 +08:00
.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;
2026-04-12 09:56:18 +08:00
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px;
border: 1px solid #edf2f7;
border-radius: 10px;
background: #f8fbff;
cursor: pointer;
2026-04-12 09:56:18 +08:00
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);
}
2026-04-12 09:56:18 +08:00
.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;
}
2026-04-12 09:56:18 +08:00
.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;
}
2026-04-12 09:56:18 +08:00
.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;
2026-04-12 09:56:18 +08:00
}
.tracking-question-answer__content :deep(.tracking-inline-citation:hover) {
border-color: #93c5fd;
background: #dbeafe;
2026-04-12 09:56:18 +08:00
}
.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;
2026-04-12 09:56:18 +08:00
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;
2026-04-12 09:56:18 +08:00
}
:deep(.modern-table .ant-table-tbody > tr:last-child > td) {
border-bottom: none;
2026-04-12 09:56:18 +08:00
}
:deep(.modern-table .ant-table-tbody > tr:hover > td) {
background-color: #f8fafc !important;
2026-04-12 09:56:18 +08:00
}
.cell-primary-stack {
2026-04-12 09:56:18 +08:00
display: flex;
flex-direction: column;
gap: 4px;
2026-04-12 09:56:18 +08:00
}
.table-link {
color: #2563eb;
font-size: 14px;
2026-04-12 09:56:18 +08:00
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;
2026-04-12 09:56:18 +08:00
}
.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;
2026-04-12 09:56:18 +08:00
}
.tracking-question-table__secondary {
color: #64748b;
font-size: 13px;
2026-04-12 09:56:18 +08:00
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
2026-04-12 09:56:18 +08:00
}
.meta-subtext {
color: #94a3b8;
font-size: 12px;
2026-04-12 09:56:18 +08:00
}
.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;
2026-04-12 09:56:18 +08:00
}
.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 {
2026-04-12 09:56:18 +08:00
align-items: flex-start;
justify-content: flex-start;
}
.tracking-task-debug-grid,
.tracking-task-debug-notes {
grid-template-columns: 1fr;
}
}
</style>