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,
|
ArticleDetail,
|
||||||
ComplianceCheckResult,
|
ComplianceCheckResult,
|
||||||
ComplianceRuntimeStatus,
|
ComplianceRuntimeStatus,
|
||||||
|
CreatePublishJobResponse,
|
||||||
GateDecisionLiteral,
|
GateDecisionLiteral,
|
||||||
|
PublishRecord,
|
||||||
} from '@geo/shared-types'
|
} from '@geo/shared-types'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { message, notification } from 'ant-design-vue'
|
import { message, notification } from 'ant-design-vue'
|
||||||
@@ -41,6 +43,7 @@ import {
|
|||||||
publishStateLabel,
|
publishStateLabel,
|
||||||
type PublishAccountCard,
|
type PublishAccountCard,
|
||||||
} from '@/lib/publish-account-cards'
|
} from '@/lib/publish-account-cards'
|
||||||
|
import { normalizePublishPlatformId } from '@/lib/publish-platforms'
|
||||||
import { useCompanyStore } from '@/stores/company'
|
import { useCompanyStore } from '@/stores/company'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -92,6 +95,20 @@ const accountsQuery = useQuery({
|
|||||||
queryFn: () => tenantAccountsApi.list(),
|
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({
|
const platformsQuery = useQuery({
|
||||||
queryKey: ['media', 'platforms', 'publish-modal'],
|
queryKey: ['media', 'platforms', 'publish-modal'],
|
||||||
enabled: computed(() => props.open),
|
enabled: computed(() => props.open),
|
||||||
@@ -127,6 +144,7 @@ watch(
|
|||||||
|
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
props.articleId ? detailQuery.refetch() : Promise.resolve(),
|
props.articleId ? detailQuery.refetch() : Promise.resolve(),
|
||||||
|
props.articleId ? publishRecordsQuery.refetch() : Promise.resolve(),
|
||||||
accountsQuery.refetch(),
|
accountsQuery.refetch(),
|
||||||
platformsQuery.refetch(),
|
platformsQuery.refetch(),
|
||||||
complianceStatusQuery.refetch(),
|
complianceStatusQuery.refetch(),
|
||||||
@@ -136,9 +154,52 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []))
|
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[]>(() =>
|
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(
|
watch(
|
||||||
@@ -173,6 +234,8 @@ watchEffect(() => {
|
|||||||
detailQuery.isFetching.value ||
|
detailQuery.isFetching.value ||
|
||||||
accountsQuery.isPending.value ||
|
accountsQuery.isPending.value ||
|
||||||
accountsQuery.isFetching.value ||
|
accountsQuery.isFetching.value ||
|
||||||
|
publishRecordsQuery.isPending.value ||
|
||||||
|
publishRecordsQuery.isFetching.value ||
|
||||||
platformsQuery.isPending.value ||
|
platformsQuery.isPending.value ||
|
||||||
platformsQuery.isFetching.value
|
platformsQuery.isFetching.value
|
||||||
) {
|
) {
|
||||||
@@ -287,6 +350,9 @@ const okText = computed(() => {
|
|||||||
return '提交发布'
|
return '提交发布'
|
||||||
})
|
})
|
||||||
const okDisabled = computed(() => {
|
const okDisabled = computed(() => {
|
||||||
|
if (coverRequired.value && !normalizedCoverValue.value) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (mandatoryAdmissionBlocking.value) {
|
if (mandatoryAdmissionBlocking.value) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -481,13 +547,19 @@ async function runPublishFlow(ackRecordId?: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePublishSuccess() {
|
async function handlePublishSuccess(result: CreatePublishJobResponse) {
|
||||||
notification.success({
|
const description = successDescription(result)
|
||||||
message: '已提交发布任务',
|
const options = {
|
||||||
description: successDescription(),
|
message: result.created_task_ids.length === 0 ? '发布任务已存在' : '已提交发布任务',
|
||||||
|
description,
|
||||||
placement: 'topRight',
|
placement: 'topRight',
|
||||||
duration: 5,
|
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([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
||||||
@@ -522,6 +594,10 @@ function handlePublishError(error: unknown) {
|
|||||||
if (normalized === 'compliance_needs_ack' || normalized === 'compliance_block') {
|
if (normalized === 'compliance_needs_ack' || normalized === 'compliance_block') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (error instanceof ApiClientError && error.message === 'desktop_publish_duplicate') {
|
||||||
|
message.warning('内容已在发布队列中,未重复提交')
|
||||||
|
return
|
||||||
|
}
|
||||||
message.error(formatError(error))
|
message.error(formatError(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,18 +618,30 @@ async function persistCoverIfNeeded(detail: ArticleDetail): Promise<ArticleDetai
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function successDescription(): string {
|
function successDescription(result: CreatePublishJobResponse): string {
|
||||||
const total = selectedCards.value.length
|
const created = result.created_task_ids.length
|
||||||
if (total === 0) {
|
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 '任务已进入发布队列。'
|
return '任务已进入发布队列。'
|
||||||
}
|
}
|
||||||
if (selectedQueuedCount.value === 0) {
|
if (selectedQueuedCount.value === 0) {
|
||||||
return `共 ${total} 个账号,在线客户端会立即开始消费。`
|
return `共 ${selectedTotal} 个账号,在线客户端会立即开始消费。`
|
||||||
}
|
}
|
||||||
if (selectedImmediateCount.value === 0) {
|
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 {
|
function toggleAccount(accountId: string, selectable: boolean): void {
|
||||||
@@ -573,6 +661,64 @@ function isSelected(accountId: string): boolean {
|
|||||||
return selectedAccountIds.value.includes(accountId)
|
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 {
|
function handleCoverToggle(checked: boolean): void {
|
||||||
if (coverRequired.value) {
|
if (coverRequired.value) {
|
||||||
coverEnabled.value = true
|
coverEnabled.value = true
|
||||||
@@ -667,8 +813,8 @@ async function publishWithAck(): Promise<void> {
|
|||||||
const ack = await complianceApi.createAck(result.record_id, {
|
const ack = await complianceApi.createAck(result.record_id, {
|
||||||
acknowledged_violation_ids: complianceAcknowledgedViolationIds.value,
|
acknowledged_violation_ids: complianceAcknowledgedViolationIds.value,
|
||||||
})
|
})
|
||||||
await runPublishFlow(ack.id)
|
const publishResult = await runPublishFlow(ack.id)
|
||||||
await handlePublishSuccess()
|
await handlePublishSuccess(publishResult)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handlePublishError(error)
|
handlePublishError(error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -784,12 +930,19 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
优先预选文章里已经勾选的平台账号。支持同平台多账号;授权异常或未绑定桌面客户端的账号不可选。
|
优先预选文章里已经勾选的平台账号。支持同平台多账号;授权异常、未绑定桌面客户端或已成功发布过该文章的账号不可选;如需重新发布,请复制或新建一篇文章。
|
||||||
|
<span v-if="publishedAccountCount > 0" class="publish-modal__published-note">
|
||||||
|
其中 {{ publishedAccountCount }} 个因已成功发布禁用。
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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"
|
class="publish-modal__loading"
|
||||||
>
|
>
|
||||||
<a-skeleton active :paragraph="{ rows: 5 }" />
|
<a-skeleton active :paragraph="{ rows: 5 }" />
|
||||||
@@ -826,9 +979,13 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
|||||||
<a-tooltip :title="account.statusText" placement="top">
|
<a-tooltip :title="account.statusText" placement="top">
|
||||||
<span
|
<span
|
||||||
class="publish-modal__state-pill publish-modal__state-pill--compact"
|
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>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -874,6 +1031,12 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
|||||||
{{ clientStatusLabel(account) }}
|
{{ clientStatusLabel(account) }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
</a-tooltip>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -1457,6 +1620,12 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) {
|
|||||||
border: 1px solid #fecaca;
|
border: 1px solid #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publish-modal__state-pill--published {
|
||||||
|
background: #dbeafe;
|
||||||
|
color: #1d4ed8;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
}
|
||||||
|
|
||||||
.publish-modal__tags {
|
.publish-modal__tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ const zhCN = {
|
|||||||
},
|
},
|
||||||
publishManagement: {
|
publishManagement: {
|
||||||
title: "发文管理",
|
title: "发文管理",
|
||||||
description: "查看桌面端发布任务、外链、账号工作台入口和重发状态。",
|
description: "查看桌面端发布任务、外链、账号工作台入口和发布结果。",
|
||||||
},
|
},
|
||||||
brands: {
|
brands: {
|
||||||
title: "品牌和词库",
|
title: "品牌和词库",
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布',
|
desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布',
|
||||||
desktop_account_not_found: '所选桌面账号不存在,请刷新后重试',
|
desktop_account_not_found: '所选桌面账号不存在,请刷新后重试',
|
||||||
desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client',
|
desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client',
|
||||||
|
desktop_publish_duplicate: '内容已在发布队列中,未重复提交',
|
||||||
desktop_publish_job_store_unavailable: '发布队列暂时不可用,请稍后重试',
|
desktop_publish_job_store_unavailable: '发布队列暂时不可用,请稍后重试',
|
||||||
compliance_blocked: '内容合规检测已阻断本次发布',
|
compliance_blocked: '内容合规检测已阻断本次发布',
|
||||||
compliance_needs_ack: '内容合规检测需要确认风险后才能发布',
|
compliance_needs_ack: '内容合规检测需要确认风险后才能发布',
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import {
|
|||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SendOutlined,
|
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import type { DesktopTaskInfo, JsonValue } from '@geo/shared-types'
|
import type { DesktopTaskInfo, JsonValue, TenantPublishTaskListResponse } from '@geo/shared-types'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
import type { TableColumnsType } from 'ant-design-vue'
|
import type { TableColumnsType } from 'ant-design-vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
@@ -26,6 +26,7 @@ import { useCompanyStore } from '@/stores/company'
|
|||||||
const PAGE_SIZE = 10
|
const PAGE_SIZE = 10
|
||||||
const INLINE_ERROR_HEAD_CHARS = 52
|
const INLINE_ERROR_HEAD_CHARS = 52
|
||||||
const INLINE_ERROR_TAIL_CHARS = 10
|
const INLINE_ERROR_TAIL_CHARS = 10
|
||||||
|
const ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS = 5_000
|
||||||
const DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE =
|
const DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE =
|
||||||
'懂车帝平台返回异常,请打开懂车帝后台查看并处理平台提示,确认账号状态后再重试。'
|
'懂车帝平台返回异常,请打开懂车帝后台查看并处理平台提示,确认账号状态后再重试。'
|
||||||
|
|
||||||
@@ -97,6 +98,10 @@ function isPendingStatus(status: PublishTaskStatus): boolean {
|
|||||||
return status === 'queued' || status === 'in_progress'
|
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 {
|
function normalizeTaskErrorMessage(messageText: string | null, platform?: string | null): string | null {
|
||||||
const normalized = extractString(messageText)
|
const normalized = extractString(messageText)
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -209,12 +214,12 @@ function summaryForTask(
|
|||||||
? '文章已保存到草稿箱,需在平台后台完成发表。'
|
? '文章已保存到草稿箱,需在平台后台完成发表。'
|
||||||
: '文章已成功发送到目标平台。'
|
: '文章已成功发送到目标平台。'
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return '本次发送失败,可以手动再次发送。'
|
return '本次发送失败,请检查账号状态或平台提示后重新创建发布。'
|
||||||
case 'aborted':
|
case 'aborted':
|
||||||
return '任务已被取消,如需继续可再次发送。'
|
return '任务已被取消,请检查原因后重新创建发布。'
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
default:
|
default:
|
||||||
return '发送结果异常,已按失败处理,可检查平台侧结果后再次发送。'
|
return '发送结果异常,已按失败处理,请检查平台侧结果后重新创建发布。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +238,7 @@ function statusMetaForTask(
|
|||||||
case 'queued':
|
case 'queued':
|
||||||
return { label: '等待发布', color: 'processing' }
|
return { label: '等待发布', color: 'processing' }
|
||||||
case 'in_progress':
|
case 'in_progress':
|
||||||
return { label: '正在发送', color: 'processing' }
|
return { label: '正在发布', color: 'processing' }
|
||||||
case 'succeeded':
|
case 'succeeded':
|
||||||
return { label: '发送成功', color: 'success' }
|
return { label: '发送成功', color: 'success' }
|
||||||
case 'failed':
|
case 'failed':
|
||||||
@@ -308,12 +313,13 @@ function buildDesktopWorkbenchUrl(record: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const companyStore = useCompanyStore()
|
const companyStore = useCompanyStore()
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const searchTitle = ref('')
|
const searchTitle = ref('')
|
||||||
const appliedTitle = ref('')
|
const appliedTitle = ref('')
|
||||||
const copiedErrorTaskId = ref<string | null>(null)
|
const copiedErrorTaskId = ref<string | null>(null)
|
||||||
|
const retryingTaskId = ref<string | null>(null)
|
||||||
|
const manualRefreshing = ref(false)
|
||||||
|
|
||||||
const accountsQuery = useQuery({
|
const accountsQuery = useQuery({
|
||||||
queryKey: ['tenant', 'desktop-accounts', 'publish-management'],
|
queryKey: ['tenant', 'desktop-accounts', 'publish-management'],
|
||||||
@@ -334,23 +340,15 @@ const tasksQuery = useQuery({
|
|||||||
page_size: PAGE_SIZE,
|
page_size: PAGE_SIZE,
|
||||||
title: appliedTitle.value || undefined,
|
title: appliedTitle.value || undefined,
|
||||||
}),
|
}),
|
||||||
})
|
refetchInterval: (query) => {
|
||||||
|
const data = query.state.data as TenantPublishTaskListResponse | undefined
|
||||||
const retryMutation = useMutation({
|
return hasActivePublishingTask(data) ? ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS : false
|
||||||
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'] }),
|
|
||||||
])
|
|
||||||
},
|
},
|
||||||
onError: (error) => message.error(formatError(error)),
|
refetchIntervalInBackground: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const loading = computed(() => tasksQuery.isPending.value || accountsQuery.isPending.value)
|
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 taskPage = computed(() => tasksQuery.data.value)
|
||||||
const pendingCount = computed(() => taskPage.value?.pending_count ?? 0)
|
const pendingCount = computed(() => taskPage.value?.pending_count ?? 0)
|
||||||
const historyTotal = computed(() => taskPage.value?.history_total ?? 0)
|
const historyTotal = computed(() => taskPage.value?.history_total ?? 0)
|
||||||
@@ -456,9 +454,17 @@ watch(searchTitle, (value) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function refresh(): void {
|
async function refresh(): Promise<void> {
|
||||||
void tasksQuery.refetch()
|
if (manualRefreshing.value) {
|
||||||
void accountsQuery.refetch()
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
manualRefreshing.value = true
|
||||||
|
try {
|
||||||
|
await Promise.all([tasksQuery.refetch(), accountsQuery.refetch()])
|
||||||
|
} finally {
|
||||||
|
manualRefreshing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySearch(): void {
|
function applySearch(): void {
|
||||||
@@ -474,6 +480,35 @@ function rowClassName(record: PublishTaskItem): string {
|
|||||||
return isPendingStatus(record.status) ? 'publish-management-row--pending' : ''
|
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> {
|
async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
||||||
if (!record.errorMessage) {
|
if (!record.errorMessage) {
|
||||||
return
|
return
|
||||||
@@ -690,28 +725,18 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
|||||||
<template #icon><DesktopOutlined /></template>
|
<template #icon><DesktopOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-popconfirm
|
<a-tooltip v-if="canRetryTask(record)" title="重新创建发布任务" placement="top">
|
||||||
v-if="!isPendingStatus(record.status)"
|
<a-button
|
||||||
title="确定要重试本次发布吗?"
|
type="text"
|
||||||
placement="topRight"
|
class="publish-action-btn"
|
||||||
ok-text="确定"
|
:loading="retryingTaskId === record.id"
|
||||||
cancel-text="取消"
|
:aria-label="`重新创建发布任务:${record.title}`"
|
||||||
@confirm="retryMutation.mutate(record.id)"
|
@click.stop="retryTask(record)"
|
||||||
>
|
>
|
||||||
<a-tooltip title="重新发送" placement="top">
|
<template #icon><RetweetOutlined /></template>
|
||||||
<a-button
|
</a-button>
|
||||||
type="text"
|
</a-tooltip>
|
||||||
class="publish-action-btn"
|
<a-tooltip v-if="isPendingStatus(record.status)" title="发布任务进行中">
|
||||||
: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="发布任务进行中">
|
|
||||||
<span class="publish-action-placeholder">
|
<span class="publish-action-placeholder">
|
||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined />
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const navItems = computed(() => {
|
|||||||
{
|
{
|
||||||
to: '/publish-management',
|
to: '/publish-management',
|
||||||
title: '发布管理',
|
title: '发布管理',
|
||||||
description: '查看待发布队列、历史发送结果,并对文章再次发送',
|
description: '查看待发布队列、历史发送结果和账号工作台',
|
||||||
count: data?.summary.queuedTasks ?? 0,
|
count: data?.summary.queuedTasks ?? 0,
|
||||||
icon: SendOutlined,
|
icon: SendOutlined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
ExportOutlined,
|
ExportOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
SendOutlined,
|
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from '@geo/shared-types'
|
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from '@geo/shared-types'
|
||||||
import { notification } from 'ant-design-vue'
|
import { notification } from 'ant-design-vue'
|
||||||
@@ -302,12 +302,12 @@ function summaryForTask(
|
|||||||
}
|
}
|
||||||
return '文章已成功发送到目标平台。'
|
return '文章已成功发送到目标平台。'
|
||||||
case 'failed':
|
case 'failed':
|
||||||
return '本次发送失败,可以手动再次发送。'
|
return '本次发送失败,请检查账号状态或平台提示后重新创建发布。'
|
||||||
case 'aborted':
|
case 'aborted':
|
||||||
return '任务已被取消,如需继续可再次发送。'
|
return '任务已被取消,请检查原因后重新创建发布。'
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
default:
|
default:
|
||||||
return '发送结果异常,已按失败处理,可检查平台侧结果后再次发送。'
|
return '发送结果异常,已按失败处理,请检查平台侧结果后重新创建发布。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,9 +326,9 @@ const searchTitle = ref('')
|
|||||||
const appliedTitle = ref('')
|
const appliedTitle = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const actionPendingTaskId = ref<string | null>(null)
|
|
||||||
const consolePendingTaskId = ref<string | null>(null)
|
const consolePendingTaskId = ref<string | null>(null)
|
||||||
const copiedErrorTaskId = ref<string | null>(null)
|
const copiedErrorTaskId = ref<string | null>(null)
|
||||||
|
const retryingTaskId = ref<string | null>(null)
|
||||||
|
|
||||||
function notifySuccess(message: string) {
|
function notifySuccess(message: string) {
|
||||||
notification.success({
|
notification.success({
|
||||||
@@ -422,22 +422,6 @@ function unbindPublishLeaseListener() {
|
|||||||
runtimeInvalidationUnsubscribe = null
|
runtimeInvalidationUnsubscribe = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function retryTask(taskId: string) {
|
|
||||||
actionPendingTaskId.value = taskId
|
|
||||||
|
|
||||||
try {
|
|
||||||
await window.desktopBridge.app.retryPublishTask(taskId)
|
|
||||||
notifySuccess('已重新加入发布队列')
|
|
||||||
await refreshTasks()
|
|
||||||
} catch (err) {
|
|
||||||
notifyActionError(
|
|
||||||
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '重新发送失败')) ?? '重新发送失败',
|
|
||||||
)
|
|
||||||
} finally {
|
|
||||||
actionPendingTaskId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openExternalUrl(url: string | null) {
|
async function openExternalUrl(url: string | null) {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return
|
return
|
||||||
@@ -473,6 +457,39 @@ async function openAccountWorkbench(record: PublishTaskItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRetryTask(record: PublishTaskItem): boolean {
|
||||||
|
return record.status === 'failed' || record.status === 'aborted' || record.status === 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryTask(record: PublishTaskItem) {
|
||||||
|
retryingTaskId.value = record.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await window.desktopBridge.app.retryPublishTask(record.id)
|
||||||
|
const created = result.created_task_ids.length
|
||||||
|
const existing = result.existing_task_ids.length
|
||||||
|
if (created > 0) {
|
||||||
|
notifySuccess(created === 1 ? '已重新创建发布任务' : `已重新创建 ${created} 个发布任务`)
|
||||||
|
} else if (existing > 0) {
|
||||||
|
if (record.status === 'unknown') {
|
||||||
|
notifyActionError('原任务结果未知且已有发布记录,可能已成功,请先核实平台侧结果。')
|
||||||
|
} else {
|
||||||
|
notifySuccess('发布任务已存在,无需重复创建')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notifySuccess('已提交重试请求')
|
||||||
|
}
|
||||||
|
await refreshTasks(1)
|
||||||
|
} catch (err) {
|
||||||
|
notifyActionError(
|
||||||
|
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '重新创建发布任务失败')) ??
|
||||||
|
'重新创建发布任务失败',
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
retryingTaskId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyTextToClipboard(text: string): Promise<void> {
|
async function copyTextToClipboard(text: string): Promise<void> {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text)
|
await navigator.clipboard.writeText(text)
|
||||||
@@ -846,26 +863,18 @@ const tableScroll = { x: 1080 } as const
|
|||||||
<template #icon><DesktopOutlined /></template>
|
<template #icon><DesktopOutlined /></template>
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
<a-popconfirm
|
<a-tooltip v-if="canRetryTask(record)" title="重新创建发布任务" placement="top">
|
||||||
v-if="!isPendingStatus(record.status)"
|
<a-button
|
||||||
title="确定要重试本次发布吗?"
|
type="text"
|
||||||
placement="topRight"
|
class="icon-action-btn"
|
||||||
ok-text="确定"
|
:loading="retryingTaskId === record.id"
|
||||||
cancel-text="取消"
|
:aria-label="`重新创建发布任务:${record.title}`"
|
||||||
@confirm="retryTask(record.id)"
|
@click.stop="retryTask(record)"
|
||||||
>
|
>
|
||||||
<a-tooltip title="再次发送" placement="top">
|
<template #icon><RetweetOutlined /></template>
|
||||||
<a-button
|
</a-button>
|
||||||
type="text"
|
</a-tooltip>
|
||||||
class="icon-action-btn"
|
<a-tooltip v-if="isPendingStatus(record.status)" title="排队中...">
|
||||||
:loading="actionPendingTaskId === record.id"
|
|
||||||
:aria-label="`再次发送:${record.title}`"
|
|
||||||
>
|
|
||||||
<template #icon><SendOutlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
</a-popconfirm>
|
|
||||||
<a-tooltip v-else title="排队中...">
|
|
||||||
<div class="action-placeholder-icon">
|
<div class="action-placeholder-icon">
|
||||||
<ClockCircleOutlined />
|
<ClockCircleOutlined />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user