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
@@ -11,7 +11,9 @@ import type {
ArticleDetail,
ComplianceCheckResult,
ComplianceRuntimeStatus,
CreatePublishJobResponse,
GateDecisionLiteral,
PublishRecord,
} from '@geo/shared-types'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { message, notification } from 'ant-design-vue'
@@ -41,6 +43,7 @@ import {
publishStateLabel,
type PublishAccountCard,
} from '@/lib/publish-account-cards'
import { normalizePublishPlatformId } from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
const props = defineProps<{
@@ -92,6 +95,20 @@ const accountsQuery = useQuery({
queryFn: () => tenantAccountsApi.list(),
})
const publishRecordsQuery = useQuery({
queryKey: computed(() => [
'articles',
'publish-records',
companyStore.currentBrandId,
props.articleId,
'publish-modal',
]),
enabled: computed(
() => props.open && Boolean(props.articleId) && Boolean(companyStore.currentBrandId),
),
queryFn: () => articlesApi.publishRecords(props.articleId as number),
})
const platformsQuery = useQuery({
queryKey: ['media', 'platforms', 'publish-modal'],
enabled: computed(() => props.open),
@@ -127,6 +144,7 @@ watch(
await Promise.allSettled([
props.articleId ? detailQuery.refetch() : Promise.resolve(),
props.articleId ? publishRecordsQuery.refetch() : Promise.resolve(),
accountsQuery.refetch(),
platformsQuery.refetch(),
complianceStatusQuery.refetch(),
@@ -136,9 +154,52 @@ watch(
)
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []))
const successfulPublishRecordKeys = computed(() =>
new Set(
(publishRecordsQuery.data.value ?? [])
.filter((record) => isSuccessfulPublishRecord(record))
.map((record) => successfulPublishRecordKey(record.desktop_account_id, record.platform_id))
.filter((key): key is string => Boolean(key)),
),
)
const activePublishRecordStatusByKey = computed(() => {
const statusByKey = new Map<string, string>()
for (const record of publishRecordsQuery.data.value ?? []) {
if (!isActivePublishRecord(record)) {
continue
}
const key = successfulPublishRecordKey(record.desktop_account_id, record.platform_id)
if (!key || statusByKey.has(key)) {
continue
}
statusByKey.set(key, record.status)
}
return statusByKey
})
const publishedAccountIds = computed(() =>
new Set(
(publishRecordsQuery.data.value ?? [])
.filter((record) => isSuccessfulPublishRecord(record))
.map((record) => record.desktop_account_id?.trim())
.filter((id): id is string => Boolean(id)),
),
)
const publishedAccountCount = computed(() => publishedAccountIds.value.size)
const accountCards = computed<PublishAccountCard[]>(() =>
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value),
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value).map((card) => {
const alreadyPublished = successfulPublishRecordKeys.value.has(
successfulPublishRecordKey(card.id, card.platformId) ?? '',
)
if (!alreadyPublished) {
return card
}
return {
...card,
selectable: false,
statusText: '这篇文章已用该账号在该平台发布成功,不能重复发布;如需重新发布,请复制或新建一篇文章。',
}
}),
)
watch(
@@ -173,6 +234,8 @@ watchEffect(() => {
detailQuery.isFetching.value ||
accountsQuery.isPending.value ||
accountsQuery.isFetching.value ||
publishRecordsQuery.isPending.value ||
publishRecordsQuery.isFetching.value ||
platformsQuery.isPending.value ||
platformsQuery.isFetching.value
) {
@@ -287,6 +350,9 @@ const okText = computed(() => {
return '提交发布'
})
const okDisabled = computed(() => {
if (coverRequired.value && !normalizedCoverValue.value) {
return true
}
if (mandatoryAdmissionBlocking.value) {
return true
}
@@ -481,13 +547,19 @@ async function runPublishFlow(ackRecordId?: number) {
})
}
async function handlePublishSuccess() {
notification.success({
message: '已提交发布任务',
description: successDescription(),
async function handlePublishSuccess(result: CreatePublishJobResponse) {
const description = successDescription(result)
const options = {
message: result.created_task_ids.length === 0 ? '发布任务已存在' : '已提交发布任务',
description,
placement: 'topRight',
duration: 5,
})
} as const
if (result.created_task_ids.length === 0 && result.existing_task_ids.length > 0) {
notification.info(options)
} else {
notification.success(options)
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['articles'] }),
@@ -522,6 +594,10 @@ function handlePublishError(error: unknown) {
if (normalized === 'compliance_needs_ack' || normalized === 'compliance_block') {
return
}
if (error instanceof ApiClientError && error.message === 'desktop_publish_duplicate') {
message.warning('内容已在发布队列中,未重复提交')
return
}
message.error(formatError(error))
}
@@ -542,18 +618,30 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
})
}
function successDescription(): string {
const total = selectedCards.value.length
if (total === 0) {
function successDescription(result: CreatePublishJobResponse): string {
const created = result.created_task_ids.length
const existing = result.existing_task_ids.length
const total = created + existing
if (total > 0) {
if (created === 0) {
return `${existing} 个账号已有发布记录或任务,未重复提交;如结果未知,请先核实平台侧结果。`
}
if (existing > 0) {
return `新建 ${created} 个发布任务;${existing} 个账号已有发布记录或任务,未重复提交。`
}
}
const selectedTotal = selectedCards.value.length
if (selectedTotal === 0) {
return '任务已进入发布队列。'
}
if (selectedQueuedCount.value === 0) {
return `${total} 个账号,在线客户端会立即开始消费。`
return `${selectedTotal} 个账号,在线客户端会立即开始消费。`
}
if (selectedImmediateCount.value === 0) {
return `${total} 个账号已进入离线队列,客户端上线后会自动继续发布。`
return `${selectedTotal} 个账号已进入离线队列,客户端上线后会自动继续发布。`
}
return `${total} 个账号:${selectedImmediateCount.value} 个立即发布,${selectedQueuedCount.value} 个离线排队。`
return `${selectedTotal} 个账号:${selectedImmediateCount.value} 个立即发布,${selectedQueuedCount.value} 个离线排队。`
}
function toggleAccount(accountId: string, selectable: boolean): void {
@@ -573,6 +661,64 @@ function isSelected(accountId: string): boolean {
return selectedAccountIds.value.includes(accountId)
}
function isAlreadyPublished(account: PublishAccountCard): boolean {
return successfulPublishRecordKeys.value.has(
successfulPublishRecordKey(account.id, account.platformId) ?? '',
)
}
function activePublishRecordStatus(account: PublishAccountCard): string | null {
return activePublishRecordStatusByKey.value.get(
successfulPublishRecordKey(account.id, account.platformId) ?? '',
) ?? null
}
function activePublishRecordLabel(account: PublishAccountCard): string | null {
switch (activePublishRecordStatus(account)) {
case 'publishing':
case 'running':
return '发布中'
case 'queued':
case 'pending':
return '已排队'
default:
return null
}
}
function successfulPublishRecordKey(
desktopAccountId?: string | null,
platformId?: string | null,
): string | null {
const accountID = String(desktopAccountId ?? '').trim()
const platform = normalizePublishPlatformId(platformId)
if (!accountID || !platform) {
return null
}
return `${accountID}:${platform}`
}
function isSuccessfulPublishRecord(record: PublishRecord): boolean {
switch (String(record.status ?? '').trim()) {
case 'success':
return Boolean(record.desktop_account_id)
default:
return false
}
}
function isActivePublishRecord(record: PublishRecord): boolean {
switch (String(record.status ?? '').trim()) {
case 'queued':
case 'pending':
case 'publishing':
case 'running':
return Boolean(record.desktop_account_id)
default:
return false
}
}
function handleCoverToggle(checked: boolean): void {
if (coverRequired.value) {
coverEnabled.value = true
@@ -667,8 +813,8 @@ async function publishWithAck(): Promise<void> {
const ack = await complianceApi.createAck(result.record_id, {
acknowledged_violation_ids: complianceAcknowledgedViolationIds.value,
})
await runPublishFlow(ack.id)
await handlePublishSuccess()
const publishResult = await runPublishFlow(ack.id)
await handlePublishSuccess(publishResult)
} catch (error) {
handlePublishError(error)
} finally {
@@ -784,12 +930,19 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
</div>
</div>
<p class="muted">
优先预选文章里已经勾选的平台账号支持同平台多账号授权异常未绑定桌面客户端的账号不可选
优先预选文章里已经勾选的平台账号支持同平台多账号授权异常未绑定桌面客户端或已成功发布过该文章的账号不可选如需重新发布请复制或新建一篇文章
<span v-if="publishedAccountCount > 0" class="publish-modal__published-note">
其中 {{ publishedAccountCount }} 个因已成功发布禁用
</span>
</p>
</div>
<div
v-if="accountsQuery.isPending.value || platformsQuery.isPending.value"
v-if="
accountsQuery.isPending.value ||
platformsQuery.isPending.value ||
publishRecordsQuery.isPending.value
"
class="publish-modal__loading"
>
<a-skeleton active :paragraph="{ rows: 5 }" />
@@ -826,9 +979,13 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
<a-tooltip :title="account.statusText" placement="top">
<span
class="publish-modal__state-pill publish-modal__state-pill--compact"
:class="`publish-modal__state-pill--${account.publishState}`"
:class="
isAlreadyPublished(account)
? 'publish-modal__state-pill--published'
: `publish-modal__state-pill--${account.publishState}`
"
>
{{ publishStateLabel(account.publishState) }}
{{ isAlreadyPublished(account) ? '已发布' : publishStateLabel(account.publishState) }}
</span>
</a-tooltip>
</div>
@@ -874,6 +1031,12 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
{{ clientStatusLabel(account) }}
</a-tag>
</a-tooltip>
<a-tag v-if="isAlreadyPublished(account)" color="blue">
已发布
</a-tag>
<a-tag v-else-if="activePublishRecordLabel(account)" color="gold">
{{ activePublishRecordLabel(account) }}
</a-tag>
</div>
</div>
</button>
@@ -1457,6 +1620,12 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
border: 1px solid #fecaca;
}
.publish-modal__state-pill--published {
background: #dbeafe;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.publish-modal__tags {
display: flex;
flex-wrap: wrap;