feat(publish-dedup): disable already-published accounts and rework retry UX

In the admin publish modal, fetch the article's publish records, disable
accounts that already succeeded for the same article/platform, label
queued/publishing accounts inline, and route the success notification by
created vs existing task counts (with a dedicated warning for the
`desktop_publish_duplicate` server error).

Replace the "再次发送" mutation/popconfirm on both admin and desktop publish
management views with a "重新创建发布任务" action that calls the same Create
endpoint, only enables on failed/aborted/unknown rows, and reports created
vs existing task counts (with a tailored warning for `unknown` results
that may have already succeeded).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 18:43:42 +08:00
parent 24dd832218
commit aff2c5f6ea
6 changed files with 311 additions and 107 deletions
@@ -5,11 +5,11 @@ import {
DesktopOutlined,
ExportOutlined,
ReloadOutlined,
RetweetOutlined,
SearchOutlined,
SendOutlined,
} from '@ant-design/icons-vue'
import type { DesktopTaskInfo, JsonValue } from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import type { DesktopTaskInfo, JsonValue, TenantPublishTaskListResponse } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { computed, ref, watch } from 'vue'
@@ -26,6 +26,7 @@ import { useCompanyStore } from '@/stores/company'
const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 52
const INLINE_ERROR_TAIL_CHARS = 10
const ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS = 5_000
const DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE =
'懂车帝平台返回异常,请打开懂车帝后台查看并处理平台提示,确认账号状态后再重试。'
@@ -97,6 +98,10 @@ function isPendingStatus(status: PublishTaskStatus): boolean {
return status === 'queued' || status === 'in_progress'
}
function hasActivePublishingTask(page: TenantPublishTaskListResponse | undefined): boolean {
return Boolean(page?.items.some((task) => normalizePublishTaskStatus(task.status) === 'in_progress'))
}
function normalizeTaskErrorMessage(messageText: string | null, platform?: string | null): string | null {
const normalized = extractString(messageText)
if (!normalized) {
@@ -209,12 +214,12 @@ function summaryForTask(
? '文章已保存到草稿箱,需在平台后台完成发表。'
: '文章已成功发送到目标平台。'
case 'failed':
return '本次发送失败,可以手动再次发送。'
return '本次发送失败,请检查账号状态或平台提示后重新创建发布。'
case 'aborted':
return '任务已被取消,如需继续可再次发送。'
return '任务已被取消,请检查原因后重新创建发布。'
case 'unknown':
default:
return '发送结果异常,已按失败处理,检查平台侧结果后再次发送。'
return '发送结果异常,已按失败处理,检查平台侧结果后重新创建发布。'
}
}
@@ -233,7 +238,7 @@ function statusMetaForTask(
case 'queued':
return { label: '等待发布', color: 'processing' }
case 'in_progress':
return { label: '正在发', color: 'processing' }
return { label: '正在发', color: 'processing' }
case 'succeeded':
return { label: '发送成功', color: 'success' }
case 'failed':
@@ -308,12 +313,13 @@ function buildDesktopWorkbenchUrl(record: {
}
const { t } = useI18n()
const queryClient = useQueryClient()
const companyStore = useCompanyStore()
const page = ref(1)
const searchTitle = ref('')
const appliedTitle = ref('')
const copiedErrorTaskId = ref<string | null>(null)
const retryingTaskId = ref<string | null>(null)
const manualRefreshing = ref(false)
const accountsQuery = useQuery({
queryKey: ['tenant', 'desktop-accounts', 'publish-management'],
@@ -334,23 +340,15 @@ const tasksQuery = useQuery({
page_size: PAGE_SIZE,
title: appliedTitle.value || undefined,
}),
})
const retryMutation = useMutation({
mutationFn: (taskId: string) => publishTasksApi.retry(taskId),
onSuccess: async () => {
message.success('已重新加入发布队列')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['tenant', 'publish-tasks'] }),
queryClient.invalidateQueries({ queryKey: ['articles'] }),
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
])
refetchInterval: (query) => {
const data = query.state.data as TenantPublishTaskListResponse | undefined
return hasActivePublishingTask(data) ? ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS : false
},
onError: (error) => message.error(formatError(error)),
refetchIntervalInBackground: false,
})
const loading = computed(() => tasksQuery.isPending.value || accountsQuery.isPending.value)
const refreshing = computed(() => tasksQuery.isFetching.value || accountsQuery.isFetching.value)
const refreshing = computed(() => manualRefreshing.value)
const taskPage = computed(() => tasksQuery.data.value)
const pendingCount = computed(() => taskPage.value?.pending_count ?? 0)
const historyTotal = computed(() => taskPage.value?.history_total ?? 0)
@@ -456,9 +454,17 @@ watch(searchTitle, (value) => {
}
})
function refresh(): void {
void tasksQuery.refetch()
void accountsQuery.refetch()
async function refresh(): Promise<void> {
if (manualRefreshing.value) {
return
}
manualRefreshing.value = true
try {
await Promise.all([tasksQuery.refetch(), accountsQuery.refetch()])
} finally {
manualRefreshing.value = false
}
}
function applySearch(): void {
@@ -474,6 +480,35 @@ function rowClassName(record: PublishTaskItem): string {
return isPendingStatus(record.status) ? 'publish-management-row--pending' : ''
}
function canRetryTask(record: PublishTaskItem): boolean {
return record.status === 'failed' || record.status === 'aborted' || record.status === 'unknown'
}
async function retryTask(record: PublishTaskItem): Promise<void> {
retryingTaskId.value = record.id
try {
const result = await publishTasksApi.retry(record.id)
const created = result.created_task_ids.length
const existing = result.existing_task_ids.length
if (created > 0) {
message.success(created === 1 ? '已重新创建发布任务' : `已重新创建 ${created} 个发布任务`)
} else if (existing > 0) {
if (record.status === 'unknown') {
message.warning('原任务结果未知且已有发布记录,可能已成功,请先核实平台侧结果。')
} else {
message.info('发布任务已存在,无需重复创建')
}
} else {
message.success('已提交重试请求')
}
await tasksQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
retryingTaskId.value = null
}
}
async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
if (!record.errorMessage) {
return
@@ -690,28 +725,18 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
<template #icon><DesktopOutlined /></template>
</a-button>
</a-tooltip>
<a-popconfirm
v-if="!isPendingStatus(record.status)"
title="确定要重试本次发布吗?"
placement="topRight"
ok-text="确定"
cancel-text="取消"
@confirm="retryMutation.mutate(record.id)"
>
<a-tooltip title="重新发送" placement="top">
<a-button
type="text"
class="publish-action-btn"
:loading="
retryMutation.isPending.value && retryMutation.variables.value === record.id
"
:aria-label="`重新发送:${record.title}`"
>
<template #icon><SendOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
<a-tooltip v-else title="发布任务进行中">
<a-tooltip v-if="canRetryTask(record)" title="重新创建发布任务" placement="top">
<a-button
type="text"
class="publish-action-btn"
:loading="retryingTaskId === record.id"
:aria-label="`重新创建发布任务:${record.title}`"
@click.stop="retryTask(record)"
>
<template #icon><RetweetOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isPendingStatus(record.status)" title="发布任务进行中">
<span class="publish-action-placeholder">
<ClockCircleOutlined />
</span>