feat(tenant): add brand publish-records list and harden enterprise site management
- Add GET /api/tenant/publish-records: brand-scoped paginated list that merges in-flight (queued/publishing) records ahead of history (success/failed/cancelled), enriched with article title and latest desktop task id; wire up service/handler/router/swagger/router_test - Rework PublishManagementView to consume publish-records instead of desktop publish tasks; add publishRecordsApi + shared-types (ListPublishRecordsParams, PublishRecordListResponse) - Enterprise site Update: distinguish explicit null from missing PATCH fields via EnterpriseSitePatchString, dedup site_url before write, verify RowsAffected, and map unique-constraint violations to a clear conflict - MediaView: enterprise site edit/delete flows with favicon fallback handling - Localize enterprise site / PBootCMS error messages to Chinese across the backend and the admin-web error map, plus legacy raw-message translation - deploy: gate migration-coupled service rollouts behind RUN_MIGRATIONS unless ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT=true; handle empty SERVICES safely Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ import {
|
||||
tenantAccountsApi,
|
||||
} from '@/lib/api'
|
||||
import { coverUploadRequired, deriveCoverFileName } from '@/lib/cover-requirements'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
|
||||
import {
|
||||
accountInitial,
|
||||
buildPublishAccountCards,
|
||||
@@ -876,7 +876,8 @@ async function handleEnterprisePublishSuccess(
|
||||
} else if (failed.length > 0) {
|
||||
notification.error({
|
||||
message: '企业站发布失败',
|
||||
description: failed[0]?.error_message || '请检查插件、栏目和 PBootCMS API 配置。',
|
||||
description:
|
||||
formatStoredErrorMessage(failed[0]?.error_message) || '请检查插件、栏目和 PBootCMS API 配置。',
|
||||
placement: 'topRight',
|
||||
duration: 6,
|
||||
})
|
||||
@@ -1094,14 +1095,14 @@ function resolveEnterpriseSiteDisabledReason(
|
||||
return '该企业站点已禁用。'
|
||||
}
|
||||
if (connectionStatus === 'error') {
|
||||
return site.last_error?.trim() || '该企业站点连接异常,请先测试连通性。'
|
||||
return formatStoredErrorMessage(site.last_error) || '该企业站点连接异常,请先测试连通性。'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function enterpriseSiteConnectionStatus(site: EnterpriseSiteConnection): string {
|
||||
const status = String(site.status ?? '').trim()
|
||||
const lastError = site.last_error?.trim()
|
||||
const lastError = formatStoredErrorMessage(site.last_error)
|
||||
const publishError = enterpriseSiteLatestPublishError(site)
|
||||
if (status === 'error' && lastError && publishError && lastError === publishError) {
|
||||
return 'connected'
|
||||
@@ -1118,7 +1119,7 @@ function enterpriseSiteLatestPublishError(site: EnterpriseSiteConnection): strin
|
||||
if (status !== 'failed' && status !== 'publish_failed') {
|
||||
return ''
|
||||
}
|
||||
return record.error_message?.trim() || ''
|
||||
return formatStoredErrorMessage(record.error_message)
|
||||
}
|
||||
|
||||
function cmsTypeLabel(cmsType: string): string {
|
||||
|
||||
@@ -85,6 +85,7 @@ import type {
|
||||
KolWorkspaceCard,
|
||||
ListCustomerSupplierMediaResourcesResponse,
|
||||
ListDesktopPublishTasksParams,
|
||||
ListPublishRecordsParams,
|
||||
ListMediaSupplyOrdersParams,
|
||||
ListMediaSupplyOrdersResponse,
|
||||
ListMediaSupplyWalletLedgersParams,
|
||||
@@ -116,6 +117,7 @@ import type {
|
||||
PublishKolPromptRequest,
|
||||
PublishEnterpriseSiteArticleRequest,
|
||||
PublishRecord,
|
||||
PublishRecordListResponse,
|
||||
Question,
|
||||
QuestionCandidateResult,
|
||||
QuestionCombinationFillRequest,
|
||||
@@ -345,6 +347,7 @@ const currentBrandScopedPathPatterns = [
|
||||
/^\/api\/tenant\/schedules(?:\/|$)/,
|
||||
/^\/api\/tenant\/instant-tasks(?:\/|$)/,
|
||||
/^\/api\/tenant\/publish-jobs(?:\/|$)/,
|
||||
/^\/api\/tenant\/publish-records(?:\/|$)/,
|
||||
/^\/api\/tenant\/publish-tasks\/[^/]+\/retry(?:\/|$)/,
|
||||
/^\/api\/tenant\/enterprise-sites\/[^/]+\/publish(?:\/|$)/,
|
||||
/^\/api\/tenant\/media-supply\/orders(?:\/|$)/,
|
||||
@@ -1386,6 +1389,14 @@ export const publishTasksApi = {
|
||||
},
|
||||
}
|
||||
|
||||
export const publishRecordsApi = {
|
||||
list(params: ListPublishRecordsParams = {}) {
|
||||
return apiClient.get<PublishRecordListResponse>('/api/tenant/publish-records', {
|
||||
params,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const enterpriseSitesApi = {
|
||||
list() {
|
||||
return apiClient.get<EnterpriseSiteConnection[]>('/api/tenant/enterprise-sites')
|
||||
|
||||
@@ -78,6 +78,7 @@ const errorMessageMap: Record<string, string> = {
|
||||
media_supply_wallet_user_not_found: '用户不存在或不属于当前租户',
|
||||
invalid_media_supply_wallet_user: '用户参数不正确',
|
||||
invalid_media_supply_wallet_delta: '调整金额不能为 0',
|
||||
invalid_id: '参数 ID 不正确',
|
||||
media_supply_credentials_required: '媒体供应商账号未配置,请联系管理员处理',
|
||||
media_supply_session_required: '媒体供应商登录状态已失效,请联系管理员处理',
|
||||
media_supply_challenge_required: '媒体供应商登录验证失败,请联系管理员处理',
|
||||
@@ -145,6 +146,59 @@ const errorMessageMap: Record<string, string> = {
|
||||
publisher_plugin_empty_response: '浏览器插件未返回数据,请刷新当前页面后重试',
|
||||
publisher_plugin_invalid_response: '浏览器插件返回了无效数据,请刷新当前页面后重试',
|
||||
publisher_plugin_unknown_error: '浏览器插件调用失败,请稍后重试',
|
||||
enterprise_site_ping_failed: '企业站点连接测试失败',
|
||||
enterprise_site_category_sync_failed: '企业站点栏目同步失败',
|
||||
enterprise_site_publish_failed: '企业站点发布失败',
|
||||
enterprise_site_disabled: '企业站点已禁用',
|
||||
enterprise_site_category_required: '请选择企业站点栏目',
|
||||
enterprise_site_categories_empty: 'CMS 未返回可发布栏目',
|
||||
enterprise_site_already_exists: '该站点域名已被其他企业站点占用',
|
||||
enterprise_site_create_failed: '企业站点创建失败',
|
||||
enterprise_site_update_failed: '企业站点更新失败',
|
||||
enterprise_site_name_required: '请输入站点名称',
|
||||
enterprise_site_appid_required: '请输入 AppID',
|
||||
enterprise_site_secret_required: '请输入 Secret',
|
||||
invalid_enterprise_site_url: '站点域名格式不正确',
|
||||
enterprise_site_cms_not_supported: '当前版本暂不支持该 CMS 类型',
|
||||
enterprise_site_query_failed: '企业站点列表读取失败',
|
||||
enterprise_site_scan_failed: '企业站点数据解析失败',
|
||||
enterprise_site_delete_failed: '企业站点删除失败',
|
||||
enterprise_site_not_found: '企业站点连接不存在或已删除',
|
||||
enterprise_site_ping_update_failed: '企业站点连接状态更新失败',
|
||||
enterprise_site_category_query_failed: '企业站点栏目读取失败',
|
||||
enterprise_site_category_scan_failed: '企业站点栏目解析失败',
|
||||
enterprise_site_category_clear_failed: '企业站点栏目同步失败',
|
||||
enterprise_site_category_payload_failed: '企业站点栏目同步失败',
|
||||
enterprise_site_category_insert_failed: '企业站点栏目保存失败',
|
||||
enterprise_site_sync_update_failed: '企业站点同步状态更新失败',
|
||||
enterprise_site_category_sync_begin_failed: '企业站点栏目同步失败',
|
||||
enterprise_site_category_sync_commit_failed: '企业站点栏目同步失败',
|
||||
enterprise_site_category_lookup_failed: '企业站点栏目校验失败',
|
||||
enterprise_site_category_not_found: '企业站点栏目不存在,请先同步栏目',
|
||||
enterprise_site_markdown_render_failed: '文章内容渲染失败',
|
||||
enterprise_site_article_empty: '文章内容为空',
|
||||
enterprise_site_publish_payload_failed: '企业站点发布记录创建失败',
|
||||
enterprise_site_publish_response_failed: 'CMS 响应保存失败',
|
||||
enterprise_site_publish_begin_failed: '企业站点发布记录创建失败',
|
||||
enterprise_site_publish_batch_failed: '企业站点发布批次创建失败',
|
||||
enterprise_site_publish_record_failed: '企业站点发布记录创建失败',
|
||||
enterprise_site_article_publish_status_failed: '文章发布状态更新失败',
|
||||
enterprise_site_publish_commit_failed: '企业站点发布记录提交失败',
|
||||
enterprise_site_publish_ack_failed: '合规确认处理失败',
|
||||
enterprise_site_publish_finish_begin_failed: '企业站点发布结果更新失败',
|
||||
enterprise_site_publish_record_update_failed: '企业站点发布记录更新失败',
|
||||
enterprise_site_publish_batch_update_failed: '企业站点发布批次更新失败',
|
||||
enterprise_site_article_publish_update_failed: '文章发布状态更新失败',
|
||||
enterprise_site_publish_connection_update_failed: '企业站点连接状态更新失败',
|
||||
enterprise_site_publish_finish_commit_failed: '企业站点发布结果提交失败',
|
||||
enterprise_site_article_lookup_failed: '文章读取失败',
|
||||
enterprise_site_article_version_lookup_failed: '文章版本读取失败',
|
||||
enterprise_site_article_version_reconstruct_failed: '文章版本内容恢复失败',
|
||||
enterprise_site_article_version_snapshot_lookup_failed: '文章版本快照读取失败',
|
||||
enterprise_site_lookup_failed: '企业站点连接读取失败',
|
||||
enterprise_site_secret_encrypt_failed: '企业站点凭证保存失败',
|
||||
enterprise_site_secret_decrypt_failed: '企业站点凭证读取失败',
|
||||
enterprise_site_url_required: '请填写站点域名',
|
||||
installation_offline: '桌面客户端未在线,请打开桌面客户端后重试',
|
||||
network_error: '网络连接失败,请稍后重试',
|
||||
request_timeout: '请求超时,AI 生成耗时较长,请稍后重试或稍候再查看结果',
|
||||
@@ -195,6 +249,17 @@ const LEGACY_MEDIA_SUPPLY_REFRESH_PATTERN =
|
||||
/^failed to refresh supplier price(?: for \d+ selected resources?)?$/i
|
||||
const LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN =
|
||||
/^supplier cost increased above locked sell price/i
|
||||
const LEGACY_CMS_CREDENTIAL_PATTERN =
|
||||
/^CMS request failed; please verify site credentials and plugin installation$/i
|
||||
const LEGACY_CMS_REQUEST_FAILED_PATTERN = /^CMS request failed$/i
|
||||
const LEGACY_ENTERPRISE_SITE_PING_FAILED_PATTERN = /^enterprise site ping failed: /i
|
||||
const LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN =
|
||||
/^enterprise site category sync failed: /i
|
||||
const LEGACY_PBOOTCMS_HTTP_PATTERN = /^pbootcms\s+\w+\s+returned http \d+$/i
|
||||
const LEGACY_PBOOTCMS_CODE_PATTERN = /^pbootcms\s+\w+\s+failed with code \d+$/i
|
||||
const LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN = /^failed to .*enterprise site/i
|
||||
const LEGACY_ENTERPRISE_SITE_EXISTS_PATTERN =
|
||||
/^(enterprise site connection already exists|another enterprise site already uses this domain)$/i
|
||||
|
||||
function translateRawErrorMessage(raw: string): string | null {
|
||||
const normalized = raw.trim()
|
||||
@@ -211,7 +276,50 @@ function translateRawErrorMessage(raw: string): string | null {
|
||||
if (LEGACY_MEDIA_SUPPLY_LOCKED_COST_PATTERN.test(normalized)) {
|
||||
return '媒体成本价已变化,当前锁定价格低于成本,请重新提交'
|
||||
}
|
||||
if (LEGACY_CMS_CREDENTIAL_PATTERN.test(normalized)) {
|
||||
return 'CMS 请求失败,请检查站点凭证和接口安装状态'
|
||||
}
|
||||
if (LEGACY_CMS_REQUEST_FAILED_PATTERN.test(normalized)) {
|
||||
return 'CMS 请求失败'
|
||||
}
|
||||
if (LEGACY_ENTERPRISE_SITE_PING_FAILED_PATTERN.test(normalized)) {
|
||||
return `企业站点连接测试失败:${formatStoredErrorMessage(
|
||||
normalized.replace(LEGACY_ENTERPRISE_SITE_PING_FAILED_PATTERN, ''),
|
||||
)}`
|
||||
}
|
||||
if (LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN.test(normalized)) {
|
||||
return `企业站点栏目同步失败:${formatStoredErrorMessage(
|
||||
normalized.replace(LEGACY_ENTERPRISE_SITE_CATEGORY_SYNC_FAILED_PATTERN, ''),
|
||||
)}`
|
||||
}
|
||||
const lower = normalized.toLowerCase()
|
||||
if (lower.startsWith('request pbootcms ')) {
|
||||
return 'CMS 请求失败,请检查站点地址和网络连通性'
|
||||
}
|
||||
if (lower.startsWith('parse pbootcms response')) {
|
||||
return 'CMS 响应解析失败,请确认接口返回 JSON 格式'
|
||||
}
|
||||
if (lower.startsWith('parse pbootcms data')) {
|
||||
return 'CMS 数据解析失败,请确认接口返回数据格式'
|
||||
}
|
||||
if (lower.startsWith('invalid pbootcms site url')) {
|
||||
return '站点域名格式不正确'
|
||||
}
|
||||
if (LEGACY_PBOOTCMS_HTTP_PATTERN.test(normalized)) {
|
||||
return 'CMS 接口请求失败,请检查站点接口是否可访问'
|
||||
}
|
||||
if (LEGACY_PBOOTCMS_CODE_PATTERN.test(normalized)) {
|
||||
return 'CMS 接口返回失败,请检查站点接口配置'
|
||||
}
|
||||
if (lower.startsWith('pbootcms ') && lower.includes(' endpoint is unavailable')) {
|
||||
return 'CMS 接口不可用,请确认接口安装路径'
|
||||
}
|
||||
if (LEGACY_ENTERPRISE_SITE_FAILED_DETAIL_PATTERN.test(normalized)) {
|
||||
return '企业站点操作失败,请稍后重试'
|
||||
}
|
||||
if (LEGACY_ENTERPRISE_SITE_EXISTS_PATTERN.test(normalized)) {
|
||||
return errorMessageMap.enterprise_site_already_exists
|
||||
}
|
||||
const hiddenSupplierToken = ['mei', 'jie', 'quan'].join('')
|
||||
if (lower.includes(hiddenSupplierToken)) {
|
||||
return '媒体供应商请求失败,请稍后重试或联系管理员'
|
||||
@@ -247,7 +355,11 @@ export function formatError(error: unknown): string {
|
||||
if (isAiPointsInsufficient(error)) {
|
||||
return message
|
||||
}
|
||||
return error.detail ? `${message}: ${error.detail}` : message
|
||||
const detail = error.detail ? formatStoredErrorMessage(error.detail) : ''
|
||||
if (detail === message) {
|
||||
return message
|
||||
}
|
||||
return detail ? `${message}:${detail}` : message
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,17 +8,17 @@ import {
|
||||
RetweetOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { DesktopTaskInfo, JsonValue, TenantPublishTaskListResponse } from '@geo/shared-types'
|
||||
import type { PublishRecord, PublishRecordListResponse } 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'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { publishTasksApi, tenantAccountsApi } from '@/lib/api'
|
||||
import { publishRecordsApi, publishTasksApi } from '@/lib/api'
|
||||
import { copyTextToClipboard } from '@/lib/clipboard'
|
||||
import { formatDateTime } from '@/lib/display'
|
||||
import { formatError } from '@/lib/errors'
|
||||
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
|
||||
import { resolvePlatformLogoUrl } from '@/lib/publish-account-cards'
|
||||
import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms'
|
||||
import { useCompanyStore } from '@/stores/company'
|
||||
@@ -26,56 +26,37 @@ 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 ACTIVE_PUBLISH_RECORD_POLL_INTERVAL_MS = 5_000
|
||||
const DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE =
|
||||
'懂车帝平台返回异常,请打开懂车帝后台查看并处理平台提示,确认账号状态后再重试。'
|
||||
|
||||
type PublishTaskStatus = DesktopTaskInfo['status']
|
||||
|
||||
interface PublishTaskItem {
|
||||
interface PublishRecordItem {
|
||||
id: string
|
||||
recordId: number
|
||||
title: string
|
||||
articleId: number | null
|
||||
articleId: number
|
||||
targetType: string
|
||||
platform: string
|
||||
platformName: string
|
||||
platformShortName: string
|
||||
platformAccent: string
|
||||
platformLogoUrl: string | null
|
||||
accountId: string
|
||||
accountName: string
|
||||
platformUid: string
|
||||
status: PublishTaskStatus
|
||||
status: string
|
||||
statusLabel: string
|
||||
statusColor: string
|
||||
summary: string
|
||||
attempts: number
|
||||
leaseExpiresAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
publishedAt: string | null
|
||||
externalUrl: string | null
|
||||
workbenchUrl: string
|
||||
publishType: string | null
|
||||
workbenchUrl: string | null
|
||||
desktopTaskId: string | null
|
||||
errorMessage: string | null
|
||||
complianceBlockedRecordId: number | null
|
||||
complianceBlockedAt: string | null
|
||||
canOpenWorkbench: boolean
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, JsonValue> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function extractNumber(value: JsonValue | null | undefined): number | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value
|
||||
}
|
||||
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
|
||||
return Number.parseInt(value.trim(), 10)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function extractString(value: JsonValue | string | null | undefined): string | null {
|
||||
function extractString(value: string | null | undefined): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
@@ -83,36 +64,30 @@ function extractString(value: JsonValue | string | null | undefined): string | n
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function extractStringId(value: JsonValue | null | undefined): string | null {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return String(Math.trunc(value))
|
||||
function normalizeRecordStatus(status: string): string {
|
||||
const normalized = status.trim().toLowerCase()
|
||||
if (normalized === 'published' || normalized === 'publish_success') {
|
||||
return 'success'
|
||||
}
|
||||
return extractString(value)
|
||||
if (normalized === 'canceled') {
|
||||
return 'cancelled'
|
||||
}
|
||||
return normalized || 'unknown'
|
||||
}
|
||||
|
||||
function normalizePublishTaskStatus(status: PublishTaskStatus): PublishTaskStatus {
|
||||
return status === 'unknown' ? 'failed' : status
|
||||
function isPendingStatus(status: string): boolean {
|
||||
return status === 'queued' || status === 'publishing'
|
||||
}
|
||||
|
||||
function isPendingStatus(status: PublishTaskStatus): boolean {
|
||||
return status === 'queued' || status === 'in_progress'
|
||||
function hasActivePublishingRecord(page: PublishRecordListResponse | undefined): boolean {
|
||||
return Boolean(page?.items.some((record) => isPendingStatus(normalizeRecordStatus(record.status))))
|
||||
}
|
||||
|
||||
function hasActivePublishingTask(page: TenantPublishTaskListResponse | undefined): boolean {
|
||||
return Boolean(page?.items.some((task) => isPendingStatus(normalizePublishTaskStatus(task.status))))
|
||||
}
|
||||
|
||||
function normalizeTaskErrorMessage(messageText: string | null, platform?: string | null): string | null {
|
||||
function normalizeRecordErrorMessage(messageText: string | null, platform?: string | null): string | null {
|
||||
const normalized = extractString(messageText)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
if (normalized.includes('scaffold-only') || normalized.includes('requires later reconcile')) {
|
||||
return '当前平台的桌面发布适配器未实现,任务已按失败处理。'
|
||||
}
|
||||
if (normalized.includes('adapter is not implemented for this platform')) {
|
||||
return '当前平台的桌面发布适配器未实现,任务已按失败处理。'
|
||||
}
|
||||
if (
|
||||
platform === 'dongchedi' &&
|
||||
(/\bserver[\s_-]*exception\b/i.test(normalized) ||
|
||||
@@ -120,71 +95,7 @@ function normalizeTaskErrorMessage(messageText: string | null, platform?: string
|
||||
) {
|
||||
return DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function complianceLevelLabel(level: string | null): string {
|
||||
const normalized = level?.trim().toLowerCase()
|
||||
switch (normalized) {
|
||||
case 'block':
|
||||
return '阻断'
|
||||
case 'high':
|
||||
return '高风险'
|
||||
case 'medium':
|
||||
return '中风险'
|
||||
case 'info':
|
||||
return '提示'
|
||||
default:
|
||||
return '风险'
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeComplianceBlockedReason(raw: string | null): string | null {
|
||||
const normalized = extractString(raw)
|
||||
if (!normalized) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized) as unknown
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return normalized
|
||||
}
|
||||
const record = parsed as Record<string, unknown>
|
||||
const manualStatus =
|
||||
typeof record.manual_review_status === 'string' ? record.manual_review_status : ''
|
||||
if (manualStatus === 'pending') {
|
||||
return '文章版本正在等待运营人工审阅,旧定时任务已停止派发。'
|
||||
}
|
||||
if (manualStatus === 'rejected') {
|
||||
return '文章版本已被运营驳回,请修改文章后重新发布。'
|
||||
}
|
||||
|
||||
const hitCount = typeof record.hit_count === 'number' ? record.hit_count : null
|
||||
const highestLevel =
|
||||
typeof record.highest_level === 'string' ? complianceLevelLabel(record.highest_level) : '风险'
|
||||
const violations = Array.isArray(record.violations) ? record.violations : []
|
||||
const matchedTerms = violations
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return null
|
||||
}
|
||||
const violation = item as Record<string, unknown>
|
||||
return typeof violation.matched_text === 'string' ? violation.matched_text.trim() : null
|
||||
})
|
||||
.filter((item): item is string => Boolean(item))
|
||||
.slice(0, 3)
|
||||
|
||||
const hitPart =
|
||||
hitCount !== null && hitCount > 0
|
||||
? `命中 ${hitCount} 条${highestLevel}规则`
|
||||
: `命中${highestLevel}规则`
|
||||
return matchedTerms.length > 0
|
||||
? `${hitPart}:${matchedTerms.join('、')}`
|
||||
: `${hitPart},请修改文章后重新发布。`
|
||||
} catch {
|
||||
return normalized === 'compliance_blocked' ? null : normalized
|
||||
}
|
||||
return formatStoredErrorMessage(normalized)
|
||||
}
|
||||
|
||||
function inlineErrorPreview(messageText: string): string {
|
||||
@@ -196,94 +107,50 @@ function inlineErrorPreview(messageText: string): string {
|
||||
return `${compact.slice(0, INLINE_ERROR_HEAD_CHARS)}...${compact.slice(-INLINE_ERROR_TAIL_CHARS)}`
|
||||
}
|
||||
|
||||
function summaryForTask(
|
||||
status: PublishTaskStatus,
|
||||
publishType: string | null = null,
|
||||
fallbackReason: string | null = null,
|
||||
): string {
|
||||
if (status === 'aborted' && fallbackReason === 'compliance_blocked') {
|
||||
return '定时发布前的合规重检拦截了本次任务,请修改文章后重新发布。'
|
||||
}
|
||||
function summaryForRecord(record: PublishRecord, status: string): string {
|
||||
const targetLabel = record.target_type === 'enterprise_site' ? '企业站点' : '目标平台'
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return '任务已进入队列,待桌面端继续执行。'
|
||||
case 'in_progress':
|
||||
return '当前正在由桌面端执行发送,请避免重复触发。'
|
||||
case 'succeeded':
|
||||
return publishType === 'draft'
|
||||
? '文章已保存到草稿箱,需在平台后台完成发表。'
|
||||
: '文章已成功发送到目标平台。'
|
||||
return record.target_type === 'enterprise_site'
|
||||
? '企业站发布请求已创建,等待服务端处理。'
|
||||
: '任务已进入桌面端发布队列。'
|
||||
case 'publishing':
|
||||
return record.target_type === 'enterprise_site'
|
||||
? '服务端正在调用站点连接发文。'
|
||||
: '桌面端正在执行发送。'
|
||||
case 'success':
|
||||
return `文章已成功发送到${targetLabel}。`
|
||||
case 'failed':
|
||||
return '本次发送失败,请检查账号状态或平台提示后重新提交发布。'
|
||||
case 'aborted':
|
||||
return '任务已被取消,请检查原因后重新提交发布。'
|
||||
return record.target_type === 'enterprise_site'
|
||||
? '本次企业站发布失败,请检查站点连接或发布错误。'
|
||||
: '本次发送失败,请检查账号状态或平台提示后重新提交。'
|
||||
case 'cancelled':
|
||||
return '本次发布已取消。'
|
||||
case 'unknown':
|
||||
default:
|
||||
return '发送结果异常,已按失败处理,请检查平台侧结果后重新提交发布。'
|
||||
return '发布结果异常,请检查目标侧结果。'
|
||||
}
|
||||
}
|
||||
|
||||
function statusMetaForTask(
|
||||
status: PublishTaskStatus,
|
||||
publishType: string | null,
|
||||
complianceBlockedRecordId: number | null,
|
||||
): { label: string; color: string } {
|
||||
if (status === 'aborted' && complianceBlockedRecordId !== null) {
|
||||
return { label: '合规阻断', color: 'error' }
|
||||
}
|
||||
if (status === 'succeeded' && publishType === 'draft') {
|
||||
return { label: '已存草稿', color: 'warning' }
|
||||
}
|
||||
function statusMetaForRecord(status: string): { label: string; color: string } {
|
||||
switch (status) {
|
||||
case 'queued':
|
||||
return { label: '等待发布', color: 'processing' }
|
||||
case 'in_progress':
|
||||
return { label: '正在发布', color: 'processing' }
|
||||
case 'succeeded':
|
||||
return { label: '发送成功', color: 'success' }
|
||||
case 'publishing':
|
||||
return { label: '发布中', color: 'processing' }
|
||||
case 'success':
|
||||
return { label: '发布成功', color: 'success' }
|
||||
case 'failed':
|
||||
case 'unknown':
|
||||
return { label: '发送失败', color: 'error' }
|
||||
case 'aborted':
|
||||
return { label: '发布失败', color: 'error' }
|
||||
case 'cancelled':
|
||||
return { label: '已取消', color: 'default' }
|
||||
case 'unknown':
|
||||
return { label: '结果异常', color: 'error' }
|
||||
default:
|
||||
return { label: status, color: 'default' }
|
||||
}
|
||||
}
|
||||
|
||||
function buildSohuArticleUrl(
|
||||
task: DesktopTaskInfo,
|
||||
result: Record<string, JsonValue>,
|
||||
): string | null {
|
||||
if (task.platform !== 'sohuhao' || extractString(result.publish_type) === 'draft') {
|
||||
return null
|
||||
}
|
||||
|
||||
const articleId = extractStringId(result.external_article_id)
|
||||
const accountUid = accountMap.value.get(task.target_account_id)?.platform_uid?.trim() ?? ''
|
||||
if (!articleId || !accountUid) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `https://www.sohu.com/a/${encodeURIComponent(articleId)}_${encodeURIComponent(accountUid)}`
|
||||
}
|
||||
|
||||
function buildWangyihaoArticleUrl(
|
||||
task: DesktopTaskInfo,
|
||||
result: Record<string, JsonValue>,
|
||||
): string | null {
|
||||
if (task.platform !== 'wangyihao' || extractString(result.publish_type) === 'draft') {
|
||||
return null
|
||||
}
|
||||
|
||||
const articleId = extractStringId(result.external_article_id)
|
||||
if (!articleId) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `https://www.163.com/dy/article/${encodeURIComponent(articleId)}.html`
|
||||
}
|
||||
|
||||
function isSafeHttpUrl(url: string | null): url is string {
|
||||
if (!url) {
|
||||
return false
|
||||
@@ -299,14 +166,13 @@ function isSafeHttpUrl(url: string | null): url is string {
|
||||
function buildDesktopWorkbenchUrl(record: {
|
||||
accountId: string
|
||||
platform: string
|
||||
platformUid: string
|
||||
accountName: string
|
||||
}): string {
|
||||
const params = new URLSearchParams({
|
||||
action: 'open-publish-account-console',
|
||||
account_id: record.accountId,
|
||||
platform: record.platform,
|
||||
platform_uid: record.platformUid,
|
||||
platform_uid: '',
|
||||
display_name: record.accountName,
|
||||
})
|
||||
return `shengxintui://desktop/workbench?${params.toString()}`
|
||||
@@ -321,128 +187,89 @@ 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'],
|
||||
queryFn: () => tenantAccountsApi.list(),
|
||||
})
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
const recordsQuery = useQuery({
|
||||
queryKey: computed(() => [
|
||||
'tenant',
|
||||
'publish-tasks',
|
||||
'publish-records',
|
||||
companyStore.currentBrandId,
|
||||
{ page: page.value, page_size: PAGE_SIZE, title: appliedTitle.value },
|
||||
]),
|
||||
enabled: computed(() => Boolean(companyStore.currentBrandId)),
|
||||
queryFn: () =>
|
||||
publishTasksApi.list({
|
||||
publishRecordsApi.list({
|
||||
page: page.value,
|
||||
page_size: PAGE_SIZE,
|
||||
title: appliedTitle.value || undefined,
|
||||
}),
|
||||
refetchInterval: (query) => {
|
||||
const data = query.state.data as TenantPublishTaskListResponse | undefined
|
||||
return hasActivePublishingTask(data) ? ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS : false
|
||||
const data = query.state.data as PublishRecordListResponse | undefined
|
||||
return hasActivePublishingRecord(data) ? ACTIVE_PUBLISH_RECORD_POLL_INTERVAL_MS : false
|
||||
},
|
||||
refetchIntervalInBackground: false,
|
||||
})
|
||||
|
||||
const loading = computed(() => tasksQuery.isPending.value || accountsQuery.isPending.value)
|
||||
const loading = computed(() => recordsQuery.isPending.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)
|
||||
const totalCount = computed(() => taskPage.value?.total ?? 0)
|
||||
const recordPage = computed(() => recordsQuery.data.value)
|
||||
const pendingCount = computed(() => recordPage.value?.pending_count ?? 0)
|
||||
const historyTotal = computed(() => recordPage.value?.history_total ?? 0)
|
||||
const totalCount = computed(() => recordPage.value?.total ?? 0)
|
||||
|
||||
const accountMap = computed(() => {
|
||||
return new Map((accountsQuery.data.value ?? []).map((account) => [account.id, account]))
|
||||
})
|
||||
|
||||
const publishTasks = computed<PublishTaskItem[]>(() =>
|
||||
(taskPage.value?.items ?? []).map((task) => {
|
||||
const status = normalizePublishTaskStatus(task.status)
|
||||
const payload = task.payload ?? {}
|
||||
const result = task.result ?? {}
|
||||
const taskError = task.error ?? {}
|
||||
const platformId = normalizePublishPlatformId(task.platform)
|
||||
const publishRecords = computed<PublishRecordItem[]>(() =>
|
||||
(recordPage.value?.items ?? []).map((record) => {
|
||||
const status = normalizeRecordStatus(record.status)
|
||||
const platformId = normalizePublishPlatformId(record.platform_id)
|
||||
const platformMeta = getPublishPlatformMeta(platformId)
|
||||
const account = accountMap.value.get(task.target_account_id)
|
||||
const publishType = extractString(result.publish_type)
|
||||
const complianceBlockedRecordId =
|
||||
task.compliance_blocked_record_id ??
|
||||
extractNumber(taskError.record_id) ??
|
||||
extractNumber(taskError.check_record_id)
|
||||
const complianceBlockedReason = summarizeComplianceBlockedReason(
|
||||
task.compliance_blocked_reason ?? extractString(taskError.detail),
|
||||
)
|
||||
const fallbackReason =
|
||||
task.publish_job_status === 'blocked_by_compliance'
|
||||
? 'compliance_blocked'
|
||||
: extractString(result.fallback_reason)
|
||||
const nestedContentRef = isPlainRecord(payload.content_ref) ? payload.content_ref : null
|
||||
const articleId =
|
||||
extractNumber(payload.article_id) ??
|
||||
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
|
||||
const accountName = account?.display_name || '待同步账号'
|
||||
const platformUid = account?.platform_uid ?? ''
|
||||
const statusMeta = statusMetaForTask(status, publishType, complianceBlockedRecordId)
|
||||
const isEnterpriseSite = record.target_type === 'enterprise_site'
|
||||
const accountName =
|
||||
extractString(record.platform_nickname) ??
|
||||
(isEnterpriseSite ? '企业自建站点' : '待同步账号')
|
||||
const statusMeta = statusMetaForRecord(status)
|
||||
const externalUrl =
|
||||
publishType === 'draft'
|
||||
? null
|
||||
: (extractString(result.external_article_url) ??
|
||||
extractString(result.externalArticleUrl) ??
|
||||
buildWangyihaoArticleUrl(task, result) ??
|
||||
buildSohuArticleUrl(task, result) ??
|
||||
extractString(result.external_manage_url) ??
|
||||
extractString(result.externalManageUrl))
|
||||
extractString(record.external_article_url) ?? extractString(record.external_manage_url)
|
||||
const desktopAccountId = extractString(record.desktop_account_id)
|
||||
const workbenchUrl =
|
||||
!isEnterpriseSite && desktopAccountId
|
||||
? buildDesktopWorkbenchUrl({
|
||||
accountId: desktopAccountId,
|
||||
platform: platformId,
|
||||
accountName,
|
||||
})
|
||||
: null
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
title: extractString(payload.title) ?? `发布任务 · ${platformMeta.name}`,
|
||||
articleId,
|
||||
id: String(record.id),
|
||||
recordId: record.id,
|
||||
title: extractString(record.article_title) ?? `文章 #${record.article_id}`,
|
||||
articleId: record.article_id,
|
||||
targetType: record.target_type ?? 'platform_account',
|
||||
platform: platformId,
|
||||
platformName: platformMeta.name,
|
||||
platformName: isEnterpriseSite ? '企业自建站点' : record.platform_name || platformMeta.name,
|
||||
platformShortName: platformMeta.shortName,
|
||||
platformAccent: platformMeta.accent,
|
||||
platformLogoUrl: resolvePlatformLogoUrl(platformId),
|
||||
accountId: task.target_account_id,
|
||||
accountName,
|
||||
platformUid,
|
||||
status,
|
||||
statusLabel: statusMeta.label,
|
||||
statusColor: statusMeta.color,
|
||||
summary: summaryForTask(status, publishType, fallbackReason),
|
||||
attempts: task.attempts,
|
||||
leaseExpiresAt: task.lease_expires_at,
|
||||
createdAt: task.created_at,
|
||||
updatedAt: task.updated_at,
|
||||
summary: summaryForRecord(record, status),
|
||||
createdAt: record.created_at,
|
||||
updatedAt: record.updated_at,
|
||||
publishedAt: record.published_at,
|
||||
externalUrl: isSafeHttpUrl(externalUrl) ? externalUrl : null,
|
||||
workbenchUrl: buildDesktopWorkbenchUrl({
|
||||
accountId: task.target_account_id,
|
||||
platform: platformId,
|
||||
platformUid,
|
||||
accountName,
|
||||
}),
|
||||
publishType,
|
||||
errorMessage: normalizeTaskErrorMessage(
|
||||
complianceBlockedReason ??
|
||||
extractString(taskError.message) ??
|
||||
extractString(taskError.detail) ??
|
||||
extractString(taskError.code),
|
||||
platformId,
|
||||
),
|
||||
complianceBlockedRecordId,
|
||||
complianceBlockedAt: task.compliance_blocked_at ?? null,
|
||||
canOpenWorkbench: Boolean(task.target_account_id),
|
||||
workbenchUrl,
|
||||
desktopTaskId: extractString(record.desktop_task_id),
|
||||
errorMessage: normalizeRecordErrorMessage(record.error_message, platformId),
|
||||
canOpenWorkbench: Boolean(workbenchUrl),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const columns = computed<TableColumnsType<PublishTaskItem>>(() => [
|
||||
const columns = computed<TableColumnsType<PublishRecordItem>>(() => [
|
||||
{ title: '发文标题', key: 'title', dataIndex: 'title', width: 280, ellipsis: true },
|
||||
{ title: '账号 / 平台', key: 'account', dataIndex: 'account', width: 190 },
|
||||
{ title: '目标', key: 'account', dataIndex: 'account', width: 190 },
|
||||
{ title: '状态', key: 'status', dataIndex: 'status', width: 110 },
|
||||
{ title: '任务信息', key: 'meta', dataIndex: 'meta', width: 320 },
|
||||
{ title: '发布信息', key: 'meta', dataIndex: 'meta', width: 320 },
|
||||
{ title: '时间', key: 'time', dataIndex: 'time', width: 170 },
|
||||
{ title: '操作', key: 'actions', align: 'right', width: 150, fixed: 'right' },
|
||||
])
|
||||
@@ -461,7 +288,7 @@ async function refresh(): Promise<void> {
|
||||
|
||||
manualRefreshing.value = true
|
||||
try {
|
||||
await Promise.all([tasksQuery.refetch(), accountsQuery.refetch()])
|
||||
await recordsQuery.refetch()
|
||||
} finally {
|
||||
manualRefreshing.value = false
|
||||
}
|
||||
@@ -476,18 +303,21 @@ function handlePageChange(nextPage: number): void {
|
||||
page.value = nextPage
|
||||
}
|
||||
|
||||
function rowClassName(record: PublishTaskItem): string {
|
||||
function rowClassName(record: PublishRecordItem): string {
|
||||
return isPendingStatus(record.status) ? 'publish-management-row--pending' : ''
|
||||
}
|
||||
|
||||
function canRetryTask(record: PublishTaskItem): boolean {
|
||||
return record.status === 'failed' || record.status === 'aborted' || record.status === 'unknown'
|
||||
function canRetryTask(record: PublishRecordItem): boolean {
|
||||
return Boolean(record.desktopTaskId && record.status === 'failed')
|
||||
}
|
||||
|
||||
async function retryTask(record: PublishTaskItem): Promise<void> {
|
||||
async function retryTask(record: PublishRecordItem): Promise<void> {
|
||||
if (!record.desktopTaskId) {
|
||||
return
|
||||
}
|
||||
retryingTaskId.value = record.id
|
||||
try {
|
||||
const result = await publishTasksApi.retry(record.id)
|
||||
const result = await publishTasksApi.retry(record.desktopTaskId)
|
||||
const created = result.created_task_ids.length
|
||||
const existing = result.existing_task_ids.length
|
||||
const requeued = result.requeued_task_ids?.length ?? 0
|
||||
@@ -504,7 +334,7 @@ async function retryTask(record: PublishTaskItem): Promise<void> {
|
||||
} else {
|
||||
message.success('已提交重试请求')
|
||||
}
|
||||
await tasksQuery.refetch()
|
||||
await recordsQuery.refetch()
|
||||
} catch (error) {
|
||||
message.error(formatError(error))
|
||||
} finally {
|
||||
@@ -512,7 +342,7 @@ async function retryTask(record: PublishTaskItem): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
||||
async function copyErrorMessage(record: PublishRecordItem): Promise<void> {
|
||||
if (!record.errorMessage) {
|
||||
return
|
||||
}
|
||||
@@ -569,8 +399,8 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
||||
<section class="publish-management-view__table-card">
|
||||
<div class="publish-management-view__toolbar">
|
||||
<div>
|
||||
<p class="eyebrow">Publish Queue</p>
|
||||
<h3>待发布与已发送记录</h3>
|
||||
<p class="eyebrow">Publish Records</p>
|
||||
<h3>待发布与已发布记录</h3>
|
||||
</div>
|
||||
<div class="publish-management-view__filters">
|
||||
<a-input
|
||||
@@ -592,12 +422,12 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
||||
row-key="id"
|
||||
class="publish-management-table"
|
||||
:columns="columns"
|
||||
:data-source="publishTasks"
|
||||
:data-source="publishRecords"
|
||||
:pagination="false"
|
||||
:loading="loading"
|
||||
:scroll="{ x: 1220 }"
|
||||
:row-class-name="rowClassName"
|
||||
:locale="{ emptyText: '暂无匹配的发文任务' }"
|
||||
:locale="{ emptyText: '暂无匹配的发文记录' }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'title'">
|
||||
@@ -664,18 +494,12 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
||||
{{ record.summary }}
|
||||
</span>
|
||||
<span class="publish-meta-footer">
|
||||
<span class="attempt-chip">尝试 {{ record.attempts }} 次</span>
|
||||
<template v-if="record.complianceBlockedRecordId">
|
||||
<span class="attempt-chip">记录 #{{ record.recordId }}</span>
|
||||
<span class="meta-divider">·</span>
|
||||
<span>{{ record.targetType === 'enterprise_site' ? '企业站点' : '媒体账号' }}</span>
|
||||
<template v-if="record.publishedAt">
|
||||
<span class="meta-divider">·</span>
|
||||
<span>合规记录 #{{ record.complianceBlockedRecordId }}</span>
|
||||
</template>
|
||||
<template v-if="record.complianceBlockedAt">
|
||||
<span class="meta-divider">·</span>
|
||||
<span>{{ formatDateTime(record.complianceBlockedAt) }} 拦截</span>
|
||||
</template>
|
||||
<template v-if="record.leaseExpiresAt">
|
||||
<span class="meta-divider">·</span>
|
||||
<span>租约至 {{ formatDateTime(record.leaseExpiresAt, 'HH:mm') }}</span>
|
||||
<span>{{ formatDateTime(record.publishedAt) }} 发布</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
@@ -714,15 +538,11 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
||||
</a-button>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip
|
||||
:title="`打开 ${record.accountName} 的${record.platformName}工作台`"
|
||||
placement="top"
|
||||
>
|
||||
<a-tooltip v-if="record.canOpenWorkbench" :title="`打开 ${record.accountName} 的${record.platformName}工作台`" placement="top">
|
||||
<a-button
|
||||
type="text"
|
||||
class="publish-action-btn"
|
||||
:href="record.workbenchUrl"
|
||||
:disabled="!record.canOpenWorkbench"
|
||||
:href="record.workbenchUrl || undefined"
|
||||
:aria-label="`打开 ${record.accountName} 的${record.platformName}工作台`"
|
||||
>
|
||||
<template #icon><DesktopOutlined /></template>
|
||||
@@ -739,7 +559,7 @@ async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
|
||||
<template #icon><RetweetOutlined /></template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="isPendingStatus(record.status)" title="发布任务进行中">
|
||||
<a-tooltip v-if="isPendingStatus(record.status)" title="发布进行中">
|
||||
<span class="publish-action-placeholder">
|
||||
<ClockCircleOutlined />
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user