diff --git a/apps/admin-web/src/components/PublishArticleModal.vue b/apps/admin-web/src/components/PublishArticleModal.vue index f251b04..f5f9c3e 100644 --- a/apps/admin-web/src/components/PublishArticleModal.vue +++ b/apps/admin-web/src/components/PublishArticleModal.vue @@ -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() + 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(() => - 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 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 { 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) {

- 优先预选文章里已经勾选的平台账号。支持同平台多账号;授权异常或未绑定桌面客户端的账号不可选。 + 优先预选文章里已经勾选的平台账号。支持同平台多账号;授权异常、未绑定桌面客户端或已成功发布过该文章的账号不可选;如需重新发布,请复制或新建一篇文章。 + + 其中 {{ publishedAccountCount }} 个因已成功发布禁用。 +

@@ -826,9 +979,13 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) { - {{ publishStateLabel(account.publishState) }} + {{ isAlreadyPublished(account) ? '已发布' : publishStateLabel(account.publishState) }}
@@ -874,6 +1031,12 @@ function decisionAlertType(result: ComplianceCheckResult | null | undefined) { {{ clientStatusLabel(account) }} + + 已发布 + + + {{ activePublishRecordLabel(account) }} + @@ -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; diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 015fd74..fc19d6e 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -219,7 +219,7 @@ const zhCN = { }, publishManagement: { title: "发文管理", - description: "查看桌面端发布任务、外链、账号工作台入口和重发状态。", + description: "查看桌面端发布任务、外链、账号工作台入口和发布结果。", }, brands: { title: "品牌和词库", diff --git a/apps/admin-web/src/lib/errors.ts b/apps/admin-web/src/lib/errors.ts index 3072490..0a1ea8f 100644 --- a/apps/admin-web/src/lib/errors.ts +++ b/apps/admin-web/src/lib/errors.ts @@ -83,6 +83,7 @@ const errorMessageMap: Record = { desktop_account_client_missing: '所选账号还没有绑定桌面客户端,暂时无法发布', desktop_account_not_found: '所选桌面账号不存在,请刷新后重试', desktop_client_offline: '当前登录账号的桌面客户端未在线,请先打开 desktop-client', + desktop_publish_duplicate: '内容已在发布队列中,未重复提交', desktop_publish_job_store_unavailable: '发布队列暂时不可用,请稍后重试', compliance_blocked: '内容合规检测已阻断本次发布', compliance_needs_ack: '内容合规检测需要确认风险后才能发布', diff --git a/apps/admin-web/src/views/PublishManagementView.vue b/apps/admin-web/src/views/PublishManagementView.vue index 488946c..3304633 100644 --- a/apps/admin-web/src/views/PublishManagementView.vue +++ b/apps/admin-web/src/views/PublishManagementView.vue @@ -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(null) +const retryingTaskId = ref(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 { + 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 { + 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 { if (!record.errorMessage) { return @@ -690,28 +725,18 @@ async function copyErrorMessage(record: PublishTaskItem): Promise { - - - - - - - - + + + + + + diff --git a/apps/desktop-client/src/renderer/components/DesktopShell.vue b/apps/desktop-client/src/renderer/components/DesktopShell.vue index f9ba39c..50c9be5 100644 --- a/apps/desktop-client/src/renderer/components/DesktopShell.vue +++ b/apps/desktop-client/src/renderer/components/DesktopShell.vue @@ -60,7 +60,7 @@ const navItems = computed(() => { { to: '/publish-management', title: '发布管理', - description: '查看待发布队列、历史发送结果,并对文章再次发送', + description: '查看待发布队列、历史发送结果和账号工作台', count: data?.summary.queuedTasks ?? 0, icon: SendOutlined, }, diff --git a/apps/desktop-client/src/renderer/views/PublishManagementView.vue b/apps/desktop-client/src/renderer/views/PublishManagementView.vue index f605510..8007341 100644 --- a/apps/desktop-client/src/renderer/views/PublishManagementView.vue +++ b/apps/desktop-client/src/renderer/views/PublishManagementView.vue @@ -5,8 +5,8 @@ import { DesktopOutlined, ExportOutlined, ReloadOutlined, + RetweetOutlined, SearchOutlined, - SendOutlined, } from '@ant-design/icons-vue' import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from '@geo/shared-types' import { notification } from 'ant-design-vue' @@ -302,12 +302,12 @@ function summaryForTask( } return '文章已成功发送到目标平台。' case 'failed': - return '本次发送失败,可以手动再次发送。' + return '本次发送失败,请检查账号状态或平台提示后重新创建发布。' case 'aborted': - return '任务已被取消,如需继续可再次发送。' + return '任务已被取消,请检查原因后重新创建发布。' case 'unknown': default: - return '发送结果异常,已按失败处理,可检查平台侧结果后再次发送。' + return '发送结果异常,已按失败处理,请检查平台侧结果后重新创建发布。' } } @@ -326,9 +326,9 @@ const searchTitle = ref('') const appliedTitle = ref('') const loading = ref(false) const error = ref(null) -const actionPendingTaskId = ref(null) const consolePendingTaskId = ref(null) const copiedErrorTaskId = ref(null) +const retryingTaskId = ref(null) function notifySuccess(message: string) { notification.success({ @@ -422,22 +422,6 @@ function unbindPublishLeaseListener() { 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) { if (!url) { 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 { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text) @@ -846,26 +863,18 @@ const tableScroll = { x: 1080 } as const - - - - - - - - + + + + + +