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:
2026-06-08 11:40:46 +08:00
parent 67be43319e
commit a44ed21967
15 changed files with 1418 additions and 764 deletions
@@ -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 {
+11
View File
@@ -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')
+113 -1
View File
@@ -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
+123 -303
View File
@@ -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>