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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user