feat: add monitoring marked articles and retention updates
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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: '榜单内容',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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%);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user