feat: add monitoring marked articles and retention updates
Frontend CI / Frontend (push) Successful in 7m47s
Backend CI / Backend (push) Successful in 19m26s

This commit is contained in:
2026-06-17 12:48:41 +08:00
parent 9ed857e159
commit 31c4dd9358
40 changed files with 2373 additions and 488 deletions
+45 -1
View File
@@ -81,6 +81,7 @@ const enUS = {
brandManagement: 'Brand Management',
tracking: 'Tracking',
trackingDetail: 'Data Details',
trackingMarkedArticles: 'External Article Marks',
contentManagement: 'Content Management',
knowledge: 'Knowledge Base',
images: 'Image Management',
@@ -264,6 +265,11 @@ const enUS = {
description:
'Inspect plugin-sampled brand performance, platform coverage, hot questions, and citation attribution.',
},
trackingMarkedArticles: {
title: 'External Article Marks',
description:
'Manage manually marked external article URLs so they count in cited articles and citation rankings.',
},
trackingQuestion: {
title: 'Question Detail',
description:
@@ -579,7 +585,7 @@ const enUS = {
citationRanking: 'Citation Ranking',
citationRankingTitle: 'Citation Ranking',
citationRankingHint:
'Attribute SaaS-published content by published-link identity across model citation sources.',
'Attribute external articles by published links and manually marked URLs across model citation sources.',
citationWindow7: 'Last 7 days',
citationWindow30: 'Last 30 days',
citationWindowLabel: 'Last {days} days',
@@ -597,6 +603,9 @@ const enUS = {
taskDebugHint:
'For troubleshooting the callback -> queue -> worker flow only. The four main content sections above stay unchanged.',
contentCitationBadge: 'Content Citation',
manualMarkedArticle: 'External Article',
markAsExternalArticle: 'Mark as external article',
markedArticleSaved: 'Marked as external article.',
noQuestions: 'No hot questions for the current filters',
noCitationRanking: 'No attributable citations in the current window',
noCitedArticles: 'No content citations in the current window',
@@ -708,6 +717,41 @@ const enUS = {
},
},
},
trackingMarkedArticles: {
title: 'External Article Marks',
description:
'Mark official-site, media, or third-party pages as external articles when they cannot be connected automatically. Marks are retained for 6 months.',
createAction: 'Mark Article',
createTitle: 'Mark External Article',
editTitle: 'Edit External Article',
openOriginal: 'Open original page',
deleteConfirmTitle: 'Delete this external article mark?',
total: '{count} total',
filters: {
domain: 'Search domain, e.g. example.com',
title: 'Search article title',
createdFrom: 'Start time',
createdTo: 'End time',
},
form: {
articleTitle: 'Article title',
articleTitlePlaceholder: 'Enter article title',
originalUrl: 'Original URL',
originalUrlPlaceholder: 'https://example.com/article',
},
columns: {
articleTitle: 'Article Title',
domain: 'Domain',
url: 'Original URL',
markedAt: 'Marked At',
expiresAt: 'Retained Until',
},
messages: {
required: 'Enter article title and original URL.',
saved: 'External article mark saved.',
deleted: 'External article mark deleted.',
},
},
templateMeta: {
top_x_article: {
eyebrow: 'Ranked Content',
+43 -1
View File
@@ -91,6 +91,7 @@ const zhCN = {
brands: '品牌和词库',
tracking: '数据追踪',
trackingDetail: '数据详情',
trackingMarkedArticles: '外部文章标记',
contentManagement: '内容管理',
knowledge: '知识库',
images: '图片管理',
@@ -264,6 +265,10 @@ const zhCN = {
title: '数据详情',
description: '围绕插件采样快照查看品牌表现、平台矩阵、高频问题和引用归因。',
},
trackingMarkedArticles: {
title: '外部文章标记',
description: '维护用户手动标记的外部文章 URL,让它们参与引用文章和引用排行统计。',
},
trackingQuestion: {
title: '问题详情',
description: '按问题查看大模型回答、引用来源、引用分析和内容引用。',
@@ -561,7 +566,7 @@ const zhCN = {
backToQuestions: '返回问题列表',
citationRanking: 'Citation Ranking',
citationRankingTitle: '引用排行',
citationRankingHint: '按发布外链主体归因 SaaS 发文在模型引用来源中的出现次数与占比。',
citationRankingHint: '按发布外链和手动标记 URL 归因外部文章在模型引用来源中的出现次数与占比。',
citationWindow7: '近 7 天',
citationWindow30: '近 30 天',
citationWindowLabel: '近 {days} 天',
@@ -578,6 +583,9 @@ const zhCN = {
taskDebugTitle: '异步任务调试',
taskDebugHint: '仅用于排查 callback -> queue -> worker 的异步流转,不影响上方四块主内容。',
contentCitationBadge: '内容引用',
manualMarkedArticle: '外部文章',
markAsExternalArticle: '标记为外部文章',
markedArticleSaved: '已标记为外部文章',
noQuestions: '当前筛选条件下暂无高频问题',
noCitationRanking: '当前时间窗口内暂无可归因引用',
noCitedArticles: '当前时间窗口内暂无内容引用',
@@ -688,6 +696,40 @@ const zhCN = {
},
},
},
trackingMarkedArticles: {
title: '外部文章标记',
description: '把无法自动对接的官网、媒体或第三方页面标记为外部文章,系统会在 6 个月内用于引用归因。',
createAction: '标记文章',
createTitle: '标记外部文章',
editTitle: '编辑外部文章',
openOriginal: '打开原始页面',
deleteConfirmTitle: '删除这条外部文章标记?',
total: '共 {count} 条',
filters: {
domain: '按域名搜索,如 example.com',
title: '按文章名称搜索',
createdFrom: '开始时间',
createdTo: '结束时间',
},
form: {
articleTitle: '文章名称',
articleTitlePlaceholder: '输入文章标题',
originalUrl: '原始 URL',
originalUrlPlaceholder: 'https://example.com/article',
},
columns: {
articleTitle: '文章名称',
domain: '域名',
url: '原始 URL',
markedAt: '标记时间',
expiresAt: '保留至',
},
messages: {
required: '请填写文章名称和原始 URL',
saved: '外部文章标记已保存',
deleted: '外部文章标记已删除',
},
},
templateMeta: {
top_x_article: {
eyebrow: '榜单内容',
+8 -1
View File
@@ -377,7 +377,14 @@ const navSections = computed<NavSection[]>(() => {
{
key: 'tracking',
title: t('nav.tracking'),
items: [{ key: '/tracking', label: t('nav.trackingDetail'), icon: LineChartOutlined }],
items: [
{ key: '/tracking', label: t('nav.trackingDetail'), icon: LineChartOutlined },
{
key: '/tracking/marked-articles',
label: t('nav.trackingMarkedArticles'),
icon: FileTextOutlined,
},
],
},
{
key: 'contentManagement',
+31
View File
@@ -103,6 +103,9 @@ import type {
MonitoringCitationSummaryResponse,
MonitoringCollectNowResponse,
MonitoringDashboardCompositeResponse,
MonitoringMarkedArticle,
MonitoringMarkedArticleListResponse,
MonitoringMarkedArticleRequest,
MonitoringQuestionDetailResponse,
PasswordPublicKeyResponse,
PlatformAccount,
@@ -1533,6 +1536,34 @@ export const monitoringApi = {
},
)
},
listMarkedArticles(params?: {
page?: number
page_size?: number
domain?: string
title?: string
created_from?: string
created_to?: string
}) {
return apiClient.get<MonitoringMarkedArticleListResponse>(
'/api/tenant/monitoring/marked-articles',
{ params },
)
},
createMarkedArticle(payload: MonitoringMarkedArticleRequest) {
return apiClient.post<MonitoringMarkedArticle, MonitoringMarkedArticleRequest>(
'/api/tenant/monitoring/marked-articles',
payload,
)
},
updateMarkedArticle(id: number, payload: MonitoringMarkedArticleRequest) {
return apiClient.put<MonitoringMarkedArticle, MonitoringMarkedArticleRequest>(
`/api/tenant/monitoring/marked-articles/${id}`,
payload,
)
},
deleteMarkedArticle(id: number) {
return apiClient.remove<{ ok: boolean }>(`/api/tenant/monitoring/marked-articles/${id}`)
},
questionDetail(
brandId: number,
questionId: number,
+14 -1
View File
@@ -26,11 +26,24 @@ const publishStatusMap: Record<string, { label: string; color: string }> = {
blocked_by_compliance: { label: 'status.publish.blocked_by_compliance', color: 'error' },
}
const goTimestampWithZonePattern =
/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})(\.\d+)?\s+([+-]\d{2})(\d{2})(?:\s+\S+)?$/
function normalizeDateTimeValue(value: string): string {
const trimmed = value.trim()
const match = trimmed.match(goTimestampWithZonePattern)
if (!match) {
return trimmed
}
const [, date, time, fraction = '', hourOffset, minuteOffset] = match
return `${date}T${time}${fraction}${hourOffset}:${minuteOffset}`
}
export function formatDateTime(value?: string | null, pattern = 'YYYY-MM-DD HH:mm'): string {
if (!value) {
return '--'
}
const parsed = dayjs(value)
const parsed = dayjs(normalizeDateTimeValue(value))
return parsed.isValid() ? parsed.format(pattern) : value
}
+10
View File
@@ -239,6 +239,16 @@ const router = createRouter({
navKey: '/tracking',
},
},
{
path: 'tracking/marked-articles',
name: 'tracking-marked-articles',
component: () => import('@/views/TrackingMarkedArticlesView.vue'),
meta: {
titleKey: 'route.trackingMarkedArticles.title',
descriptionKey: 'route.trackingMarkedArticles.description',
navKey: '/tracking/marked-articles',
},
},
{
path: 'tracking/questions/:brandId/:questionId',
name: 'tracking-question-detail',
@@ -0,0 +1,472 @@
<script setup lang="ts">
import {
DeleteOutlined,
EditOutlined,
EyeOutlined,
PlusOutlined,
ReloadOutlined,
SearchOutlined,
} from '@ant-design/icons-vue'
import type { MonitoringMarkedArticle, MonitoringMarkedArticleRequest } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { TableColumnsType, TablePaginationConfig } from 'ant-design-vue'
import { message, Modal } from 'ant-design-vue'
import type { Dayjs } from 'dayjs'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import PageHero from '@/components/PageHero.vue'
import { monitoringApi } from '@/lib/api'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { useCompanyStore } from '@/stores/company'
const { t } = useI18n()
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const page = ref(1)
const pageSize = ref(10)
const createdRange = ref<[Dayjs, Dayjs] | null>(null)
const draftFilters = reactive({
domain: '',
title: '',
})
const appliedFilters = reactive({
domain: '',
title: '',
created_from: '',
created_to: '',
})
const modalOpen = ref(false)
const editingArticle = ref<MonitoringMarkedArticle | null>(null)
const formState = reactive({
article_title: '',
original_url: '',
})
const listParams = computed(() => ({
page: page.value,
page_size: pageSize.value,
domain: appliedFilters.domain || undefined,
title: appliedFilters.title || undefined,
created_from: appliedFilters.created_from || undefined,
created_to: appliedFilters.created_to || undefined,
}))
const markedArticlesQuery = useQuery({
queryKey: computed(() => ['tracking', 'marked-articles', listParams.value]),
queryFn: () => monitoringApi.listMarkedArticles(listParams.value),
})
const markedArticles = computed(() => markedArticlesQuery.data.value?.items ?? [])
const pageInfo = computed(() => markedArticlesQuery.data.value?.page)
const total = computed(() => pageInfo.value?.total ?? 0)
const modalTitle = computed(() =>
editingArticle.value
? t('trackingMarkedArticles.editTitle')
: t('trackingMarkedArticles.createTitle'),
)
const columns = computed<TableColumnsType<MonitoringMarkedArticle>>(() => [
{
title: t('trackingMarkedArticles.columns.articleTitle'),
dataIndex: 'article_title',
key: 'article_title',
width: 320,
},
{
title: t('trackingMarkedArticles.columns.domain'),
dataIndex: 'registrable_domain',
key: 'registrable_domain',
width: 180,
},
{
title: t('trackingMarkedArticles.columns.url'),
dataIndex: 'original_url',
key: 'original_url',
ellipsis: true,
},
{
title: t('trackingMarkedArticles.columns.markedAt'),
dataIndex: 'marked_at',
key: 'marked_at',
width: 180,
},
{
title: t('trackingMarkedArticles.columns.expiresAt'),
dataIndex: 'expires_at',
key: 'expires_at',
width: 180,
},
{
title: t('common.actions'),
key: 'actions',
width: 140,
fixed: 'right',
align: 'right' as const,
},
])
const createMutation = useMutation({
mutationFn: (payload: MonitoringMarkedArticleRequest) =>
monitoringApi.createMarkedArticle(payload),
onSuccess: async () => {
message.success(t('trackingMarkedArticles.messages.saved'))
closeModal()
await invalidateMarkedArticles()
},
onError: (error) => message.error(formatError(error)),
})
const updateMutation = useMutation({
mutationFn: (payload: { id: number; body: MonitoringMarkedArticleRequest }) =>
monitoringApi.updateMarkedArticle(payload.id, payload.body),
onSuccess: async () => {
message.success(t('trackingMarkedArticles.messages.saved'))
closeModal()
await invalidateMarkedArticles()
},
onError: (error) => message.error(formatError(error)),
})
const deleteMutation = useMutation({
mutationFn: (id: number) => monitoringApi.deleteMarkedArticle(id),
onSuccess: async () => {
message.success(t('trackingMarkedArticles.messages.deleted'))
await invalidateMarkedArticles()
},
onError: (error) => message.error(formatError(error)),
})
const saving = computed(() => createMutation.isPending.value || updateMutation.isPending.value)
function applyFilters(): void {
page.value = 1
appliedFilters.domain = draftFilters.domain.trim()
appliedFilters.title = draftFilters.title.trim()
appliedFilters.created_from = createdRange.value?.[0]?.toISOString() ?? ''
appliedFilters.created_to = createdRange.value?.[1]?.toISOString() ?? ''
}
function resetFilters(): void {
draftFilters.domain = ''
draftFilters.title = ''
createdRange.value = null
applyFilters()
}
function openCreateModal(): void {
editingArticle.value = null
formState.article_title = ''
formState.original_url = ''
modalOpen.value = true
}
function openEditModal(record: MonitoringMarkedArticle): void {
editingArticle.value = record
formState.article_title = record.article_title
formState.original_url = record.original_url
modalOpen.value = true
}
function closeModal(): void {
modalOpen.value = false
editingArticle.value = null
formState.article_title = ''
formState.original_url = ''
}
async function submitForm(): Promise<void> {
const payload: MonitoringMarkedArticleRequest = {
article_title: formState.article_title.trim(),
original_url: formState.original_url.trim(),
brand_id: companyStore.currentBrandId ?? null,
}
if (!payload.article_title || !payload.original_url) {
message.warning(t('trackingMarkedArticles.messages.required'))
return
}
if (editingArticle.value) {
await updateMutation.mutateAsync({ id: editingArticle.value.id, body: payload })
return
}
await createMutation.mutateAsync(payload)
}
function confirmDelete(record: MonitoringMarkedArticle): void {
Modal.confirm({
title: t('trackingMarkedArticles.deleteConfirmTitle'),
content: record.article_title,
okText: t('common.delete'),
okType: 'danger',
cancelText: t('common.cancel'),
onOk: () => deleteMutation.mutateAsync(record.id),
})
}
function openURL(url: string): void {
const normalized = url.trim()
if (!normalized) {
return
}
window.open(normalized, '_blank', 'noopener,noreferrer')
}
function handleTableChange(pagination: TablePaginationConfig): void {
page.value = pagination.current ?? 1
pageSize.value = pagination.pageSize ?? pageSize.value
}
function renderTotal(count: number): string {
return t('trackingMarkedArticles.total', { count })
}
async function invalidateMarkedArticles(): Promise<void> {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tracking', 'marked-articles'] }),
queryClient.invalidateQueries({ queryKey: ['tracking'] }),
])
}
</script>
<template>
<section class="marked-articles-page">
<PageHero
:title="t('trackingMarkedArticles.title')"
:description="t('trackingMarkedArticles.description')"
>
<template #actions>
<a-button @click="markedArticlesQuery.refetch()">
<template #icon><ReloadOutlined /></template>
{{ t('common.refresh') }}
</a-button>
<a-button type="primary" @click="openCreateModal">
<template #icon><PlusOutlined /></template>
{{ t('trackingMarkedArticles.createAction') }}
</a-button>
</template>
</PageHero>
<a-card class="marked-articles-card">
<div class="marked-articles-filters">
<a-input
v-model:value="draftFilters.domain"
allow-clear
class="marked-articles-filters__domain"
:placeholder="t('trackingMarkedArticles.filters.domain')"
@pressEnter="applyFilters"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-input
v-model:value="draftFilters.title"
allow-clear
class="marked-articles-filters__title"
:placeholder="t('trackingMarkedArticles.filters.title')"
@pressEnter="applyFilters"
/>
<a-range-picker
v-model:value="createdRange"
allow-clear
show-time
class="marked-articles-filters__range"
format="YYYY-MM-DD HH:mm"
:placeholder="[
t('trackingMarkedArticles.filters.createdFrom'),
t('trackingMarkedArticles.filters.createdTo'),
]"
/>
<div class="marked-articles-filters__actions">
<a-button type="primary" @click="applyFilters">{{ t('common.search') }}</a-button>
<a-button @click="resetFilters">{{ t('common.reset') }}</a-button>
</div>
</div>
<a-table
row-key="id"
class="modern-table marked-articles-table"
:columns="columns"
:data-source="markedArticles"
:loading="markedArticlesQuery.isLoading.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: renderTotal,
}"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'article_title'">
<div class="marked-article-title">
<strong :title="record.article_title">{{ record.article_title }}</strong>
<span>{{ record.publish_platform }}</span>
</div>
</template>
<template v-else-if="column.key === 'registrable_domain'">
<a-tag color="blue">{{ record.registrable_domain }}</a-tag>
</template>
<template v-else-if="column.key === 'original_url'">
<a
:href="record.original_url"
target="_blank"
rel="noreferrer"
class="marked-article-url"
:title="record.original_url"
>
{{ record.original_url }}
</a>
</template>
<template v-else-if="column.key === 'marked_at'">
{{ formatDateTime(record.marked_at) }}
</template>
<template v-else-if="column.key === 'expires_at'">
{{ formatDateTime(record.expires_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<div class="marked-article-actions">
<a-tooltip :title="t('trackingMarkedArticles.openOriginal')">
<a-button type="text" shape="circle" @click="openURL(record.original_url)">
<template #icon><EyeOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('common.edit')">
<a-button type="text" shape="circle" @click="openEditModal(record)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('common.delete')">
<a-button
type="text"
danger
shape="circle"
:loading="deleteMutation.isPending.value"
@click="confirmDelete(record)"
>
<template #icon><DeleteOutlined /></template>
</a-button>
</a-tooltip>
</div>
</template>
</template>
</a-table>
</a-card>
<a-modal
v-model:open="modalOpen"
:title="modalTitle"
:confirm-loading="saving"
width="560px"
@ok="submitForm"
@cancel="closeModal"
>
<a-form layout="vertical" class="marked-article-form">
<a-form-item :label="t('trackingMarkedArticles.form.articleTitle')" required>
<a-input
v-model:value="formState.article_title"
:maxlength="160"
:placeholder="t('trackingMarkedArticles.form.articleTitlePlaceholder')"
/>
</a-form-item>
<a-form-item :label="t('trackingMarkedArticles.form.originalUrl')" required>
<a-input
v-model:value="formState.original_url"
:placeholder="t('trackingMarkedArticles.form.originalUrlPlaceholder')"
/>
</a-form-item>
</a-form>
</a-modal>
</section>
</template>
<style scoped>
.marked-articles-page {
display: flex;
flex-direction: column;
gap: 20px;
}
.marked-articles-card {
border: 1px solid #edf1f7;
border-radius: 12px;
}
.marked-articles-filters {
display: grid;
grid-template-columns: minmax(180px, 240px) minmax(220px, 1fr) minmax(280px, 360px) auto;
gap: 12px;
align-items: center;
margin-bottom: 18px;
}
.marked-articles-filters__actions {
display: inline-flex;
gap: 8px;
justify-content: flex-end;
}
.marked-articles-table :deep(.ant-table-cell) {
vertical-align: middle;
}
.marked-article-title {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.marked-article-title strong {
overflow: hidden;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.marked-article-title span {
color: #8a96aa;
font-size: 12px;
}
.marked-article-url {
display: inline-block;
max-width: 100%;
overflow: hidden;
color: #2563eb;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.marked-article-actions {
display: inline-flex;
gap: 4px;
}
.marked-article-form {
padding-top: 8px;
}
@media (max-width: 1100px) {
.marked-articles-filters {
grid-template-columns: 1fr 1fr;
}
.marked-articles-filters__range,
.marked-articles-filters__actions {
width: 100%;
}
.marked-articles-filters__actions {
justify-content: flex-start;
}
}
@media (max-width: 720px) {
.marked-articles-filters {
grid-template-columns: 1fr;
}
}
</style>
@@ -1,12 +1,13 @@
<script setup lang="ts">
import { ArrowLeftOutlined, CopyOutlined } from '@ant-design/icons-vue'
import { ArrowLeftOutlined, BookOutlined, CopyOutlined } from '@ant-design/icons-vue'
import type {
MonitoringQuestionCitationAnalysisItem,
MonitoringQuestionDetailCitation,
MonitoringQuestionDetailPlatform,
MonitoringSourceItem,
} from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message } from 'ant-design-vue'
import dayjs from 'dayjs'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -22,6 +23,7 @@ import { useCompanyStore } from '@/stores/company'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const trackingMaxHistoryDays = 30
@@ -179,6 +181,7 @@ type TrackingAnalyzedContentCitation = {
citationCount: number
citationRate: number | null
citedURL: string | null
sourceType: string | null
}
type TrackingCitationSourceLookupItem = {
@@ -202,6 +205,20 @@ const shouldShowInlineContentCitations = computed(() => {
return activeInlineContentCitations.value.length > 0
})
const markCitationMutation = useMutation({
mutationFn: (citation: MonitoringQuestionDetailCitation) =>
monitoringApi.createMarkedArticle({
article_title: resolveExternalArticleTitle(citation),
original_url: citation.cited_url,
brand_id: brandId.value ?? null,
}),
onSuccess: async () => {
message.success(t('tracking.markedArticleSaved'))
await queryClient.invalidateQueries({ queryKey: ['tracking'] })
},
onError: (error) => message.error(formatError(error)),
})
watch(activePlatformId, () => {
clearCitationHighlight()
})
@@ -248,6 +265,13 @@ function openImitationCreate(
})
}
function markCitationAsExternal(citation: MonitoringQuestionDetailCitation): void {
if (!canMarkCitationAsExternal(citation, activePlatform.value?.ai_platform_id)) {
return
}
markCitationMutation.mutate(citation)
}
function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
@@ -311,6 +335,37 @@ function resolveCitationTitle(citation: MonitoringQuestionDetailCitation): strin
return citation.cited_title || citation.article_title || citation.cited_url
}
function resolveExternalArticleTitle(citation: MonitoringQuestionDetailCitation): string {
const title = String(citation.cited_title ?? citation.article_title ?? '').trim()
if (title) {
return title
}
return String(citation.site_name ?? citation.cited_url).trim()
}
function isManualMarkedCitation(
citation: MonitoringQuestionDetailCitation | null | undefined,
): boolean {
return citation?.source_type === 'manual_mark'
}
function canMarkCitationAsExternal(
citation: MonitoringQuestionDetailCitation | null | undefined,
platformId: string | null | undefined,
): boolean {
if (!citation || !supportsContentCitationDisplay(platformId)) {
return false
}
if (!String(citation.cited_url ?? '').trim()) {
return false
}
return (
!citation.article_id &&
citation.source_type !== 'published_article' &&
!isManualMarkedCitation(citation)
)
}
function isLinkedInlineCitationPlatform(platformId: string | null | undefined): boolean {
return platformId === 'yuanbao'
}
@@ -1118,6 +1173,7 @@ function analyzeInlineContentCitations(
citationCount: item.citationCount,
citationRate: item.citationCount / totalCitationCount,
citedURL: item.url,
sourceType: null,
}))
}
@@ -1151,6 +1207,7 @@ function analyzeInlineContentCitations(
citationCount: item.citationCount,
citationRate: item.citationCount / totalCitationCount,
citedURL: item.url,
sourceType: null,
}
})
}
@@ -1199,6 +1256,7 @@ function analyzeInlineContentCitations(
citationCount,
citationRate: citationCount / totalCitationCount,
citedURL,
sourceType: citation?.source_type ?? null,
},
]
})
@@ -1503,17 +1561,36 @@ const contentCitationColumns = computed(() => [
>
{{ 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))
"
<div class="tracking-question-source-card__actions">
<a-tooltip
v-if="canMarkCitationAsExternal(citation, activePlatform?.ai_platform_id)"
:title="t('tracking.markAsExternalArticle')"
>
<CopyOutlined />
</button>
</a-tooltip>
<button
type="button"
class="tracking-question-imitation-trigger"
:disabled="markCitationMutation.isPending.value"
@click.stop="markCitationAsExternal(citation)"
@keydown.enter.stop.prevent="markCitationAsExternal(citation)"
>
<BookOutlined />
</button>
</a-tooltip>
<a-tooltip :title="t('tracking.imitationAction')">
<button
type="button"
class="tracking-question-imitation-trigger"
@click.stop="
openImitationCreate(citation.cited_url, resolveCitationTitle(citation))
"
@keydown.enter.stop.prevent="
openImitationCreate(citation.cited_url, resolveCitationTitle(citation))
"
>
<CopyOutlined />
</button>
</a-tooltip>
</div>
<a-tag
v-if="
citation.article_id &&
@@ -1524,6 +1601,16 @@ const contentCitationColumns = computed(() => [
>
{{ t('tracking.contentCitationBadge') }}
</a-tag>
<a-tag
v-else-if="
isManualMarkedCitation(citation) &&
supportsContentCitationDisplay(activePlatform?.ai_platform_id)
"
color="green"
class="tracking-question-source-card__badge"
>
{{ t('tracking.manualMarkedArticle') }}
</a-tag>
</div>
<div class="tracking-question-source-card__link">
{{ citation.cited_url }}
@@ -1627,16 +1714,20 @@ const contentCitationColumns = computed(() => [
<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>
<template v-if="record.citedURL">
<a
:href="record.citedURL"
target="_blank"
rel="noreferrer"
:title="record.contentName"
class="table-link"
>
{{ record.contentName }}
</a>
<small v-if="record.sourceType === 'manual_mark'" class="meta-subtext">
{{ t('tracking.manualMarkedArticle') }}
</small>
</template>
<strong v-else class="table-text" :title="record.contentName">
{{ record.contentName }}
</strong>
@@ -2139,10 +2230,17 @@ const contentCitationColumns = computed(() => [
background: #eff6ff;
}
.tracking-question-imitation-trigger--source {
.tracking-question-imitation-trigger:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.tracking-question-source-card__actions {
position: absolute;
top: 50%;
right: 16px;
display: inline-flex;
gap: 6px;
transform: translateY(calc(-50% + 1px));
}
@@ -2152,9 +2250,8 @@ const contentCitationColumns = computed(() => [
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;
.tracking-question-source-card:hover .tracking-question-source-card__actions,
.tracking-question-source-card:focus-within .tracking-question-source-card__actions {
transform: translateY(-50%);
}
+58 -13
View File
@@ -1,12 +1,12 @@
<script setup lang="ts">
import {
EyeOutlined,
ReloadOutlined,
QuestionCircleOutlined,
ClockCircleOutlined,
EyeOutlined,
FileTextOutlined,
QuestionCircleOutlined,
ReloadOutlined,
RightOutlined,
SearchOutlined,
FileTextOutlined,
} from '@ant-design/icons-vue'
import type {
DesktopAccountInfo,
@@ -189,7 +189,10 @@ watch(
() => route.query.question_id,
(value) => {
const requestedQuestionId = parseNumericQuery(route.query.question_id)
if (missingRequestedQuestionId.value && missingRequestedQuestionId.value !== requestedQuestionId) {
if (
missingRequestedQuestionId.value &&
missingRequestedQuestionId.value !== requestedQuestionId
) {
missingRequestedQuestionId.value = null
}
if (value || selectedBrandId.value) {
@@ -691,9 +694,18 @@ watch([selectedBrandId, selectedQuestionId, selectedPlatformId, selectedBusiness
hotQuestionPage.value = 1
})
watch([selectedBrandId, selectedQuestionId, selectedPlatformId, selectedBusinessDate, selectedCitationWindowDays], () => {
citedArticlePage.value = 1
})
watch(
[
selectedBrandId,
selectedQuestionId,
selectedPlatformId,
selectedBusinessDate,
selectedCitationWindowDays,
],
() => {
citedArticlePage.value = 1
},
)
function openQuestion(question: MonitoringHotQuestion): void {
if (!selectedBrandId.value) {
@@ -718,10 +730,30 @@ function openQuestion(question: MonitoringHotQuestion): void {
}
function openCitedArticle(item: MonitoringCitedArticle): void {
if (item.source_type === 'manual_mark') {
openExternalURL(item.source_url)
return
}
if (!item.article_id) {
openExternalURL(item.source_url)
return
}
selectedArticleId.value = item.article_id
articleDrawerOpen.value = true
}
function citedArticleKey(item: MonitoringCitedArticle): string {
return `${item.source_type}:${item.source_id || item.source_url || item.article_id || item.article_title}`
}
function openExternalURL(url: string | null | undefined): void {
const normalized = String(url ?? '').trim()
if (!normalized) {
return
}
window.open(normalized, '_blank', 'noopener,noreferrer')
}
function formatPercent(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '--'
@@ -1308,7 +1340,10 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
<strong>{{ question.question_text }}</strong>
<div class="mention-rate-container">
<span class="mention-rate-label">{{ t('tracking.metrics.mentionRate') }}</span>
<span class="mention-rate-badge" :class="getMentionRateClass(question.mention_rate)">
<span
class="mention-rate-badge"
:class="getMentionRateClass(question.mention_rate)"
>
{{ formatPercent(question.mention_rate) }}
</span>
<div class="mention-rate-track">
@@ -1368,7 +1403,7 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
<div v-if="citedArticles.length" class="article-list">
<div
v-for="item in citedArticles"
:key="item.article_id"
:key="citedArticleKey(item)"
class="article-list__item"
>
<div class="article-list__item-left">
@@ -1377,7 +1412,13 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
</div>
<div class="article-list__item-content">
<strong>{{ item.article_title }}</strong>
<span class="article-platform-tag">{{ item.publish_platform }}</span>
<span class="article-platform-tag">
{{
item.source_type === 'manual_mark'
? t('tracking.manualMarkedArticle')
: item.publish_platform
}}
</span>
</div>
</div>
<div class="article-list__side">
@@ -1693,7 +1734,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.tracking-list-search.ant-input-affix-wrapper:focus {
background-color: #ffffff;
border-color: #1f5cff;
box-shadow: 0 0 0 3px rgba(31, 92, 255, 0.1), 0 4px 12px rgba(31, 92, 255, 0.05);
box-shadow:
0 0 0 3px rgba(31, 92, 255, 0.1),
0 4px 12px rgba(31, 92, 255, 0.05);
}
.tracking-list-search :deep(.ant-input) {
@@ -2035,7 +2078,9 @@ function disableTrackingDate(current: dayjs.Dayjs): boolean {
.detail-citation-list__item:hover {
background: #ffffff;
border-color: rgba(31, 92, 255, 0.25);
box-shadow: 0 10px 25px -5px rgba(31, 92, 255, 0.08), 0 8px 10px -6px rgba(31, 92, 255, 0.04);
box-shadow:
0 10px 25px -5px rgba(31, 92, 255, 0.08),
0 8px 10px -6px rgba(31, 92, 255, 0.04);
transform: translateY(-2px);
}