6fa9a6c052
Map dongchedi adapter failures (raw "server exception" or dongchedi_platform_exception) to a Chinese prompt steering the user to the dongchedi backend, so renderer/admin views and desktop-task error payloads no longer surface the opaque platform message.
1374 lines
38 KiB
Vue
1374 lines
38 KiB
Vue
<script setup lang="ts">
|
||
import {
|
||
ClockCircleOutlined,
|
||
CopyOutlined,
|
||
DesktopOutlined,
|
||
ExportOutlined,
|
||
ReloadOutlined,
|
||
SearchOutlined,
|
||
SendOutlined,
|
||
} from '@ant-design/icons-vue'
|
||
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from '@geo/shared-types'
|
||
import { notification } from 'ant-design-vue'
|
||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
|
||
import { normalizePublisherErrorMessage } from '../../shared/publisher-errors'
|
||
import StatusBadge from '../components/StatusBadge.vue'
|
||
import { useDesktopRuntime } from '../composables/useDesktopRuntime'
|
||
import { unwrapClientErrorMessage } from '../lib/client-errors'
|
||
import { formatDateTime, formatRelativeTime } from '../lib/formatters'
|
||
import {
|
||
desktopPlatformShortName,
|
||
desktopPublishMediaCatalog,
|
||
translateDesktopPlatform,
|
||
} from '../lib/media-catalog'
|
||
|
||
const PAGE_SIZE = 10
|
||
const INLINE_ERROR_HEAD_CHARS = 48
|
||
const INLINE_ERROR_TAIL_CHARS = 10
|
||
const ACTIVE_TASK_POLL_INTERVAL_MS = 5_000
|
||
|
||
const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[number]> =
|
||
Object.fromEntries(desktopPublishMediaCatalog.map((item) => [item.id, item]))
|
||
|
||
interface PublishTaskItem {
|
||
id: string
|
||
title: string
|
||
articleId: number | null
|
||
platform: string
|
||
accountId: string
|
||
accountName: string
|
||
status: DesktopTaskInfo['status']
|
||
summary: string
|
||
attempts: number
|
||
leaseExpiresAt: number | null
|
||
createdAt: number
|
||
updatedAt: number
|
||
externalArticleUrl: string | null
|
||
publishType: string | null
|
||
fallbackReason: string | null
|
||
errorMessage: string | null
|
||
complianceBlockedRecordId: number | null
|
||
complianceBlockedAt: number | null
|
||
}
|
||
|
||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||
let runtimeInvalidationUnsubscribe: (() => void) | null = null
|
||
|
||
function platformMeta(platform: string) {
|
||
const key = (platform ?? '').toLowerCase()
|
||
return publishPlatformIndex[key] ?? null
|
||
}
|
||
|
||
function translatePlatform(platform: string) {
|
||
return platformMeta(platform)?.label || translateDesktopPlatform(platform)
|
||
}
|
||
|
||
function platformLogo(platform: string): string | null {
|
||
return platformMeta(platform)?.logoUrl ?? null
|
||
}
|
||
|
||
function platformAccent(platform: string): string {
|
||
return platformMeta(platform)?.accent ?? '#64748b'
|
||
}
|
||
|
||
function platformShortName(platform: string): string {
|
||
return platformMeta(platform)?.shortName ?? desktopPlatformShortName(platform)
|
||
}
|
||
|
||
function statusLabel(status: DesktopTaskInfo['status']): string {
|
||
const map: Record<DesktopTaskInfo['status'], string> = {
|
||
queued: '等待发布',
|
||
in_progress: '正在发送',
|
||
succeeded: '发送成功',
|
||
failed: '发送失败',
|
||
unknown: '发送失败',
|
||
aborted: '已取消',
|
||
}
|
||
return map[status]
|
||
}
|
||
|
||
function isComplianceBlockedTask(task: PublishTaskItem): boolean {
|
||
return task.status === 'aborted' && task.complianceBlockedRecordId !== null
|
||
}
|
||
|
||
function statusLabelForTask(task: PublishTaskItem): string {
|
||
if (isComplianceBlockedTask(task)) {
|
||
return '合规阻断'
|
||
}
|
||
if (task.status === 'succeeded' && task.publishType === 'draft') {
|
||
return '已存草稿'
|
||
}
|
||
return statusLabel(task.status)
|
||
}
|
||
|
||
function isPendingStatus(status: DesktopTaskInfo['status']): boolean {
|
||
return status === 'queued' || status === 'in_progress'
|
||
}
|
||
|
||
function hasActivePublishingTask(page: DesktopPublishTaskListResponse | null): boolean {
|
||
return Boolean(
|
||
page?.items.some((task) => normalizePublishTaskStatus(task.status) === 'in_progress'),
|
||
)
|
||
}
|
||
|
||
function statusTone(status: DesktopTaskInfo['status']): 'info' | 'warn' | 'success' | 'danger' {
|
||
switch (status) {
|
||
case 'queued':
|
||
case 'in_progress':
|
||
return 'warn'
|
||
case 'succeeded':
|
||
return 'success'
|
||
case 'failed':
|
||
case 'unknown':
|
||
case 'aborted':
|
||
default:
|
||
return 'danger'
|
||
}
|
||
}
|
||
|
||
function statusToneForTask(task: PublishTaskItem): 'info' | 'warn' | 'success' | 'danger' {
|
||
if (task.status === 'succeeded' && task.publishType === 'draft') {
|
||
return 'warn'
|
||
}
|
||
return statusTone(task.status)
|
||
}
|
||
|
||
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 | null | undefined): string | null {
|
||
if (typeof value !== 'string') {
|
||
return null
|
||
}
|
||
const trimmed = value.trim()
|
||
return trimmed ? trimmed : null
|
||
}
|
||
|
||
function extractStringId(value: JsonValue | null | undefined): string | null {
|
||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||
return String(Math.trunc(value))
|
||
}
|
||
return extractString(value)
|
||
}
|
||
|
||
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)?.platformUid?.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 normalizePublishTaskStatus(status: DesktopTaskInfo['status']): DesktopTaskInfo['status'] {
|
||
return status === 'unknown' ? 'failed' : status
|
||
}
|
||
|
||
function normalizeTaskErrorMessage(message: string | null, platform?: string | null): string | null {
|
||
const normalized = extractString(message)
|
||
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 '当前平台的桌面发布适配器未实现,任务已按失败处理。'
|
||
}
|
||
return normalizePublisherErrorMessage(normalized, platform) ?? 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
|
||
}
|
||
}
|
||
|
||
function inlineErrorPreview(message: string): string {
|
||
const compact = message.replace(/\s+/g, ' ').trim()
|
||
const maxLength = INLINE_ERROR_HEAD_CHARS + INLINE_ERROR_TAIL_CHARS + 3
|
||
if (compact.length <= maxLength) {
|
||
return compact
|
||
}
|
||
return `${compact.slice(0, INLINE_ERROR_HEAD_CHARS)}...${compact.slice(-INLINE_ERROR_TAIL_CHARS)}`
|
||
}
|
||
|
||
function summaryForTask(
|
||
status: DesktopTaskInfo['status'],
|
||
publishType: string | null = null,
|
||
fallbackReason: string | null = null,
|
||
): string {
|
||
if (status === 'aborted' && fallbackReason === 'compliance_blocked') {
|
||
return '定时发布前的合规重检拦截了本次任务,请修改文章后重新发布。'
|
||
}
|
||
switch (status) {
|
||
case 'queued':
|
||
return '任务已进入队列,待前面的发布任务完成后继续执行。'
|
||
case 'in_progress':
|
||
return '当前正在由桌面端执行发送,请避免重复触发。'
|
||
case 'succeeded':
|
||
if (publishType === 'draft') {
|
||
if (fallbackReason === 'publish_authorization_required') {
|
||
return '草稿已保存,需在公众号后台完成微信验证/授权发布。'
|
||
}
|
||
return '文章已保存到草稿箱,需在平台后台完成发表。'
|
||
}
|
||
return '文章已成功发送到目标平台。'
|
||
case 'failed':
|
||
return '本次发送失败,可以手动再次发送。'
|
||
case 'aborted':
|
||
return '任务已被取消,如需继续可再次发送。'
|
||
case 'unknown':
|
||
default:
|
||
return '发送结果异常,已按失败处理,可检查平台侧结果后再次发送。'
|
||
}
|
||
}
|
||
|
||
function parseTimestamp(value: string | null): number {
|
||
if (!value) {
|
||
return Date.now()
|
||
}
|
||
const timestamp = Date.parse(value)
|
||
return Number.isNaN(timestamp) ? Date.now() : timestamp
|
||
}
|
||
|
||
const { snapshot } = useDesktopRuntime()
|
||
const taskPage = ref<DesktopPublishTaskListResponse | null>(null)
|
||
const currentPage = ref(1)
|
||
const searchTitle = ref('')
|
||
const appliedTitle = ref('')
|
||
const loading = ref(false)
|
||
const error = ref<string | null>(null)
|
||
const actionPendingTaskId = ref<string | null>(null)
|
||
const consolePendingTaskId = ref<string | null>(null)
|
||
const copiedErrorTaskId = ref<string | null>(null)
|
||
|
||
function notifySuccess(message: string) {
|
||
notification.success({
|
||
message,
|
||
placement: 'topRight',
|
||
duration: 2.4,
|
||
})
|
||
}
|
||
|
||
function notifyActionError(description: string) {
|
||
notification.error({
|
||
message: '操作失败',
|
||
description,
|
||
placement: 'topRight',
|
||
duration: 4,
|
||
})
|
||
}
|
||
|
||
async function refreshTasks(page = currentPage.value) {
|
||
loading.value = true
|
||
error.value = null
|
||
|
||
try {
|
||
const response = await window.desktopBridge.app.listPublishTasks({
|
||
page,
|
||
page_size: PAGE_SIZE,
|
||
title: appliedTitle.value || undefined,
|
||
})
|
||
|
||
const lastPage = response.total > 0 ? Math.ceil(response.total / PAGE_SIZE) : 1
|
||
if (response.total > 0 && page > lastPage) {
|
||
currentPage.value = lastPage
|
||
await refreshTasks(lastPage)
|
||
return
|
||
}
|
||
|
||
taskPage.value = response
|
||
currentPage.value = response.page
|
||
syncActiveTaskPolling(response)
|
||
} catch (err) {
|
||
error.value = normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '发布任务暂时不可用'))
|
||
if (!hasActivePublishingTask(taskPage.value)) {
|
||
stopActiveTaskPolling()
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function startActiveTaskPolling() {
|
||
if (refreshTimer) {
|
||
return
|
||
}
|
||
refreshTimer = setInterval(() => {
|
||
void refreshTasks()
|
||
}, ACTIVE_TASK_POLL_INTERVAL_MS)
|
||
}
|
||
|
||
function stopActiveTaskPolling() {
|
||
if (!refreshTimer) {
|
||
return
|
||
}
|
||
clearInterval(refreshTimer)
|
||
refreshTimer = null
|
||
}
|
||
|
||
function syncActiveTaskPolling(page = taskPage.value) {
|
||
if (hasActivePublishingTask(page)) {
|
||
startActiveTaskPolling()
|
||
return
|
||
}
|
||
stopActiveTaskPolling()
|
||
}
|
||
|
||
function bindPublishLeaseListener() {
|
||
if (runtimeInvalidationUnsubscribe || !window.desktopBridge?.app?.onRuntimeInvalidated) {
|
||
return
|
||
}
|
||
|
||
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated((event) => {
|
||
if (event.reason !== 'publish-task-lease') {
|
||
return
|
||
}
|
||
|
||
void refreshTasks(1)
|
||
})
|
||
}
|
||
|
||
function unbindPublishLeaseListener() {
|
||
runtimeInvalidationUnsubscribe?.()
|
||
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
|
||
}
|
||
|
||
try {
|
||
await window.desktopBridge.app.openExternalUrl(url)
|
||
} catch (err) {
|
||
notifyActionError(
|
||
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '打开外链失败')) ?? '打开外链失败',
|
||
)
|
||
}
|
||
}
|
||
|
||
async function openAccountWorkbench(record: PublishTaskItem) {
|
||
const account = accountMap.value.get(record.accountId) ?? null
|
||
consolePendingTaskId.value = record.id
|
||
|
||
try {
|
||
await window.desktopBridge.app.openPublishAccountConsole({
|
||
id: record.accountId,
|
||
platform: record.platform,
|
||
platformUid: account?.platformUid ?? '',
|
||
displayName: account?.displayName ?? record.accountName,
|
||
})
|
||
} catch (err) {
|
||
notifyActionError(
|
||
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '打开账号工作台失败')) ??
|
||
'打开账号工作台失败',
|
||
)
|
||
} finally {
|
||
consolePendingTaskId.value = null
|
||
}
|
||
}
|
||
|
||
async function copyTextToClipboard(text: string): Promise<void> {
|
||
if (navigator.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(text)
|
||
return
|
||
}
|
||
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = text
|
||
textarea.setAttribute('readonly', 'true')
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.left = '-9999px'
|
||
document.body.appendChild(textarea)
|
||
textarea.select()
|
||
document.execCommand('copy')
|
||
document.body.removeChild(textarea)
|
||
}
|
||
|
||
async function copyErrorMessage(record: PublishTaskItem) {
|
||
if (!record.errorMessage) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
await copyTextToClipboard(record.errorMessage)
|
||
copiedErrorTaskId.value = record.id
|
||
notifySuccess('错误详情已复制')
|
||
window.setTimeout(() => {
|
||
if (copiedErrorTaskId.value === record.id) {
|
||
copiedErrorTaskId.value = null
|
||
}
|
||
}, 1_500)
|
||
} catch (err) {
|
||
notifyActionError(
|
||
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '复制错误详情失败')) ??
|
||
'复制错误详情失败',
|
||
)
|
||
}
|
||
}
|
||
|
||
function applySearch() {
|
||
currentPage.value = 1
|
||
appliedTitle.value = searchTitle.value.trim()
|
||
void refreshTasks(1)
|
||
}
|
||
|
||
function handlePageChange(page: number) {
|
||
currentPage.value = page
|
||
void refreshTasks(page)
|
||
}
|
||
|
||
function rowClassName(record: PublishTaskItem) {
|
||
return isPendingStatus(record.status) ? 'publish-row--pending' : ''
|
||
}
|
||
|
||
watch(searchTitle, (value) => {
|
||
if (!value.trim() && appliedTitle.value) {
|
||
appliedTitle.value = ''
|
||
currentPage.value = 1
|
||
void refreshTasks(1)
|
||
}
|
||
})
|
||
|
||
onMounted(() => {
|
||
bindPublishLeaseListener()
|
||
void refreshTasks()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
unbindPublishLeaseListener()
|
||
stopActiveTaskPolling()
|
||
})
|
||
|
||
const accountNameMap = computed(() => {
|
||
return new Map((snapshot.value?.accounts ?? []).map((item) => [item.id, item.displayName]))
|
||
})
|
||
|
||
const accountMap = computed(() => {
|
||
return new Map((snapshot.value?.accounts ?? []).map((item) => [item.id, item]))
|
||
})
|
||
|
||
const publishTasks = computed<PublishTaskItem[]>(() =>
|
||
(taskPage.value?.items ?? []).map((task) => {
|
||
const normalizedStatus = normalizePublishTaskStatus(task.status)
|
||
const payload = task.payload ?? {}
|
||
const result = task.result ?? {}
|
||
const taskError = task.error ?? {}
|
||
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 =
|
||
payload.content_ref &&
|
||
typeof payload.content_ref === 'object' &&
|
||
!Array.isArray(payload.content_ref)
|
||
? (payload.content_ref as Record<string, JsonValue>)
|
||
: null
|
||
const articleId =
|
||
extractNumber(payload.article_id) ??
|
||
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
|
||
|
||
return {
|
||
id: task.id,
|
||
title: extractString(payload.title) ?? `发布任务 · ${translatePlatform(task.platform)}`,
|
||
articleId,
|
||
platform: task.platform,
|
||
accountId: task.target_account_id,
|
||
accountName: accountNameMap.value.get(task.target_account_id) ?? '待同步账号',
|
||
status: normalizedStatus,
|
||
summary: summaryForTask(normalizedStatus, publishType, fallbackReason),
|
||
attempts: task.attempts,
|
||
leaseExpiresAt: task.lease_expires_at ? parseTimestamp(task.lease_expires_at) : null,
|
||
createdAt: parseTimestamp(task.created_at),
|
||
updatedAt: parseTimestamp(task.updated_at),
|
||
externalArticleUrl:
|
||
publishType === 'draft'
|
||
? null
|
||
: (extractString(result.external_article_url) ??
|
||
buildWangyihaoArticleUrl(task, result) ??
|
||
buildSohuArticleUrl(task, result)),
|
||
publishType,
|
||
fallbackReason,
|
||
errorMessage: normalizeTaskErrorMessage(
|
||
complianceBlockedReason ??
|
||
extractString(taskError.message) ??
|
||
extractString(taskError.detail) ??
|
||
extractString(taskError.code),
|
||
task.platform,
|
||
),
|
||
complianceBlockedRecordId,
|
||
complianceBlockedAt: task.compliance_blocked_at ? parseTimestamp(task.compliance_blocked_at) : null,
|
||
}
|
||
}),
|
||
)
|
||
|
||
const pendingCount = computed(() => taskPage.value?.pending_count ?? 0)
|
||
const historyTotal = computed(() => taskPage.value?.history_total ?? 0)
|
||
const totalCount = computed(() => taskPage.value?.total ?? 0)
|
||
|
||
const tableColumns = [
|
||
{ title: '文章', key: 'title', dataIndex: 'title', width: 260, ellipsis: true },
|
||
{ title: '账号 / 平台', key: 'account', dataIndex: 'account', width: 168 },
|
||
{ title: '状态', key: 'status', dataIndex: 'status', width: 96 },
|
||
{ title: '任务信息', key: 'meta', dataIndex: 'meta', width: 280 },
|
||
{ title: '时间', key: 'time', dataIndex: 'time', width: 132 },
|
||
{ title: '操作', key: 'actions', align: 'right' as const, width: 132, fixed: 'right' as const },
|
||
]
|
||
|
||
const tableScroll = { x: 1080 } as const
|
||
</script>
|
||
|
||
<template>
|
||
<section class="page-container">
|
||
<section class="hero-card">
|
||
<div class="hero-header">
|
||
<div class="hero-title">
|
||
<p class="eyebrow">发布执行台</p>
|
||
<h2>发布管理</h2>
|
||
</div>
|
||
<div class="hero-actions">
|
||
<a-button
|
||
type="primary"
|
||
ghost
|
||
class="modern-btn"
|
||
:loading="loading"
|
||
@click="refreshTasks()"
|
||
>
|
||
<template #icon><ReloadOutlined /></template>
|
||
刷新状态
|
||
</a-button>
|
||
</div>
|
||
</div>
|
||
<div class="stats-strip">
|
||
<div class="stat-card">
|
||
<div class="stat-label">等待发布</div>
|
||
<div class="stat-value" :class="{ 'stat-value--accent': pendingCount > 0 }">
|
||
{{ pendingCount }}
|
||
</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">已发送</div>
|
||
<div class="stat-value">{{ historyTotal }}</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">历史总数</div>
|
||
<div class="stat-value">{{ totalCount }}</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div v-if="error" class="feedback-banner error">
|
||
<div class="error-text">{{ error }}</div>
|
||
</div>
|
||
|
||
<section class="content-section">
|
||
<div class="hero-card" style="padding-bottom: 0">
|
||
<div class="hero-header table-toolbar">
|
||
<div class="toolbar-meta">
|
||
<h3 class="section-title">待发布与已发送记录</h3>
|
||
<p class="section-desc">
|
||
待发布任务始终排在最前,每页 10 条;分页与总数以服务端返回的发布任务为准。
|
||
</p>
|
||
</div>
|
||
<div class="toolbar-filters">
|
||
<a-input
|
||
v-model:value="searchTitle"
|
||
allow-clear
|
||
placeholder="搜索文章标题"
|
||
class="search-input"
|
||
@pressEnter="applySearch"
|
||
>
|
||
<template #prefix>
|
||
<SearchOutlined style="color: #94a3b8" />
|
||
</template>
|
||
</a-input>
|
||
<a-button type="primary" class="modern-btn" @click="applySearch">搜索</a-button>
|
||
</div>
|
||
</div>
|
||
|
||
<a-table
|
||
row-key="id"
|
||
class="modern-table publish-table"
|
||
:columns="tableColumns"
|
||
:data-source="publishTasks"
|
||
:pagination="false"
|
||
:loading="loading"
|
||
:scroll="tableScroll"
|
||
:row-class-name="rowClassName"
|
||
>
|
||
<template #emptyText>
|
||
<div class="empty-cell">
|
||
<a-empty description="暂无匹配的发布任务" />
|
||
</div>
|
||
</template>
|
||
|
||
<template #bodyCell="{ column, record }">
|
||
<template v-if="column.key === 'title'">
|
||
<div class="info-stack">
|
||
<a-tooltip :title="record.title" placement="topLeft">
|
||
<span class="cell-title">{{ record.title }}</span>
|
||
</a-tooltip>
|
||
<span class="cell-sub">
|
||
<template v-if="record.articleId">#{{ record.articleId }}</template>
|
||
<template v-else>未关联文章</template>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'account'">
|
||
<div class="info-stack">
|
||
<span class="cell-title cell-title--mono">{{ record.accountName }}</span>
|
||
<span class="cell-sub platform-line">
|
||
<span class="platform-logo-mini">
|
||
<img
|
||
v-if="platformLogo(record.platform)"
|
||
:src="platformLogo(record.platform) || ''"
|
||
:alt="translatePlatform(record.platform)"
|
||
/>
|
||
<span
|
||
v-else
|
||
class="platform-logo-fallback"
|
||
:style="{ color: platformAccent(record.platform) }"
|
||
>
|
||
{{ platformShortName(record.platform) }}
|
||
</span>
|
||
</span>
|
||
<span>{{ translatePlatform(record.platform) }}</span>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'status'">
|
||
<StatusBadge :tone="statusToneForTask(record)" :label="statusLabelForTask(record)" />
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'meta'">
|
||
<div class="info-stack meta-cell">
|
||
<span v-if="record.errorMessage" class="error-message-row">
|
||
<a-popover
|
||
placement="topLeft"
|
||
overlay-class-name="publish-error-popover"
|
||
trigger="hover"
|
||
>
|
||
<template #content>
|
||
<div class="error-popover-panel" @click.stop>
|
||
<div class="error-popover-header">发布错误</div>
|
||
<pre class="error-popover-content">{{ record.errorMessage }}</pre>
|
||
<button
|
||
class="popover-copy-btn"
|
||
type="button"
|
||
:aria-label="
|
||
copiedErrorTaskId === record.id ? '错误详情已复制' : '复制错误详情'
|
||
"
|
||
@click.stop="copyErrorMessage(record)"
|
||
>
|
||
<CopyOutlined />
|
||
<span>
|
||
{{ copiedErrorTaskId === record.id ? '已复制' : '复制错误详情' }}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</template>
|
||
<span class="error-text error-text--truncate">
|
||
{{ inlineErrorPreview(record.errorMessage) }}
|
||
</span>
|
||
</a-popover>
|
||
</span>
|
||
<span v-else class="cell-sub strong-text">{{ record.summary }}</span>
|
||
<span class="cell-sub meta-footer">
|
||
<span class="attempt-chip">尝试 {{ record.attempts }} 次</span>
|
||
<template v-if="record.complianceBlockedRecordId">
|
||
<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>租约 {{ formatRelativeTime(record.leaseExpiresAt) }} 到期</span>
|
||
</template>
|
||
</span>
|
||
</div>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'time'">
|
||
<a-tooltip
|
||
:title="`创建 ${formatDateTime(record.createdAt)} · 更新 ${formatDateTime(record.updatedAt)}`"
|
||
placement="topLeft"
|
||
>
|
||
<div class="info-stack time-stack">
|
||
<span class="cell-title cell-title--mono">
|
||
{{ formatRelativeTime(record.updatedAt) }}
|
||
</span>
|
||
<span class="cell-sub">{{ formatDateTime(record.updatedAt) }}</span>
|
||
</div>
|
||
</a-tooltip>
|
||
</template>
|
||
|
||
<template v-else-if="column.key === 'actions'">
|
||
<div class="action-buttons">
|
||
<a-tooltip v-if="record.externalArticleUrl" title="打开外链" placement="top">
|
||
<a-button
|
||
type="text"
|
||
class="icon-action-btn"
|
||
:aria-label="`打开外链:${record.title}`"
|
||
@click.stop="openExternalUrl(record.externalArticleUrl)"
|
||
>
|
||
<template #icon><ExportOutlined /></template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
<a-tooltip
|
||
:title="`打开 ${record.accountName} 的${translatePlatform(record.platform)}工作台`"
|
||
placement="top"
|
||
>
|
||
<a-button
|
||
type="text"
|
||
class="icon-action-btn"
|
||
:loading="consolePendingTaskId === record.id"
|
||
:aria-label="`打开 ${record.accountName} 的${translatePlatform(record.platform)}工作台`"
|
||
@click.stop="openAccountWorkbench(record)"
|
||
>
|
||
<template #icon><DesktopOutlined /></template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
<a-popconfirm
|
||
v-if="!isPendingStatus(record.status)"
|
||
title="确定要重试本次发布吗?"
|
||
placement="topRight"
|
||
ok-text="确定"
|
||
cancel-text="取消"
|
||
@confirm="retryTask(record.id)"
|
||
>
|
||
<a-tooltip title="再次发送" placement="top">
|
||
<a-button
|
||
type="text"
|
||
class="icon-action-btn"
|
||
:loading="actionPendingTaskId === record.id"
|
||
:aria-label="`再次发送:${record.title}`"
|
||
>
|
||
<template #icon><SendOutlined /></template>
|
||
</a-button>
|
||
</a-tooltip>
|
||
</a-popconfirm>
|
||
<a-tooltip v-else title="排队中...">
|
||
<div class="action-placeholder-icon">
|
||
<ClockCircleOutlined />
|
||
</div>
|
||
</a-tooltip>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
</a-table>
|
||
|
||
<div class="table-footer">
|
||
<a-pagination
|
||
:current="currentPage"
|
||
:page-size="PAGE_SIZE"
|
||
:total="totalCount"
|
||
:show-size-changer="false"
|
||
@change="handlePageChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 32px;
|
||
padding-bottom: 40px;
|
||
width: 100%;
|
||
max-width: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* Hero Section (aligned with AccountsView) */
|
||
.hero-card {
|
||
background: #ffffff;
|
||
border-radius: 20px;
|
||
border: 1px solid #eef2f6;
|
||
box-shadow:
|
||
0 4px 6px -1px rgba(0, 0, 0, 0.02),
|
||
0 2px 4px -2px rgba(0, 0, 0, 0.01);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.hero-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
padding: 36px 40px;
|
||
}
|
||
|
||
.eyebrow {
|
||
margin: 0 0 12px;
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.hero-title h2 {
|
||
margin: 0;
|
||
font-size: 32px;
|
||
font-weight: 800;
|
||
color: #0f172a;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
|
||
.modern-btn {
|
||
border-radius: 8px;
|
||
height: 40px;
|
||
padding: 0 20px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Stats Strip */
|
||
.stats-strip {
|
||
display: flex;
|
||
padding: 24px 40px;
|
||
background: #f8fafc;
|
||
border-top: 1px solid #eef2f6;
|
||
gap: 40px;
|
||
}
|
||
|
||
.stat-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
flex: 1;
|
||
}
|
||
|
||
.stat-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #64748b;
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 40px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
line-height: 1;
|
||
font-feature-settings: 'tnum';
|
||
}
|
||
|
||
.stat-value--accent {
|
||
color: #b45309;
|
||
}
|
||
|
||
/* Content Section / Table Toolbar */
|
||
.content-section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
.table-toolbar {
|
||
padding: 24px 32px 16px;
|
||
border-bottom: 1px solid #eef2f6;
|
||
align-items: center;
|
||
gap: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.toolbar-meta {
|
||
flex: 1 1 240px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.section-title {
|
||
margin: 0;
|
||
font-size: 20px;
|
||
font-weight: 700;
|
||
color: #0f172a;
|
||
}
|
||
|
||
.section-desc {
|
||
margin: 6px 0 0;
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.toolbar-filters {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.search-input {
|
||
width: 240px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
/* Modern Enterprise Table (aligned with AccountsView) */
|
||
:deep(.publish-table) {
|
||
padding: 0 32px 16px;
|
||
}
|
||
|
||
:deep(.publish-table .ant-table) {
|
||
background: transparent;
|
||
}
|
||
|
||
:deep(.publish-table .ant-table-thead > tr > th) {
|
||
background-color: transparent !important;
|
||
color: #64748b;
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid #e2e8f0;
|
||
padding: 16px 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
:deep(.publish-table .ant-table-tbody > tr > td) {
|
||
padding: 16px 12px;
|
||
border-bottom: 1px solid #f1f5f9;
|
||
color: #0f172a;
|
||
vertical-align: middle;
|
||
transition: background-color 0.15s ease;
|
||
font-size: 13px;
|
||
}
|
||
|
||
:deep(.publish-table .ant-table-tbody > tr:last-child > td) {
|
||
border-bottom: none;
|
||
}
|
||
|
||
:deep(.publish-table .ant-table-tbody > tr:hover > td) {
|
||
background-color: #f8fafc !important;
|
||
}
|
||
|
||
:deep(.publish-table .ant-table-cell-fix-right) {
|
||
background-color: #ffffff !important;
|
||
}
|
||
|
||
:deep(.publish-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-right) {
|
||
background-color: #f8fafc !important;
|
||
}
|
||
|
||
:deep(.publish-table .publish-row--pending > td) {
|
||
background: #fffbeb !important;
|
||
}
|
||
|
||
:deep(.publish-table .publish-row--pending > td.ant-table-cell-fix-right) {
|
||
background: #fffbeb !important;
|
||
}
|
||
|
||
.empty-cell {
|
||
padding: 56px 20px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
/* Cell content */
|
||
.info-stack {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.cell-title {
|
||
font-weight: 600;
|
||
color: #0f172a;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cell-title--mono {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-weight: 500;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.cell-sub {
|
||
color: #64748b;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
display: block;
|
||
margin-top: 4px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.cell-sub.strong-text {
|
||
color: #1e293b;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.platform-line {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.platform-logo-mini {
|
||
width: 16px;
|
||
height: 16px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: #ffffff;
|
||
border-radius: 4px;
|
||
box-shadow: inset 0 0 0 1px #e2e8f0;
|
||
overflow: hidden;
|
||
flex: 0 0 auto;
|
||
}
|
||
|
||
.platform-logo-mini img {
|
||
width: 12px;
|
||
height: 12px;
|
||
object-fit: contain;
|
||
}
|
||
|
||
.platform-logo-fallback {
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
|
||
.time-stack {
|
||
cursor: default;
|
||
}
|
||
|
||
.meta-cell {
|
||
width: 100%;
|
||
}
|
||
|
||
.meta-footer {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
white-space: normal;
|
||
font-size: 11.5px;
|
||
color: #94a3b8;
|
||
}
|
||
|
||
.attempt-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
height: 18px;
|
||
padding: 0 6px;
|
||
border-radius: 4px;
|
||
background: #f1f5f9;
|
||
color: #475569;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
}
|
||
|
||
.meta-divider {
|
||
color: #cbd5e1;
|
||
}
|
||
|
||
.error-message-row {
|
||
display: block;
|
||
width: 100%;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.error-text {
|
||
color: #dc2626;
|
||
font-size: 12.5px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.error-text--truncate {
|
||
display: block;
|
||
min-width: 0;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.error-message-row :deep(.ant-popover-open) {
|
||
min-width: 0;
|
||
display: block;
|
||
max-width: 100%;
|
||
}
|
||
|
||
/* Action Buttons */
|
||
.action-buttons {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 4px;
|
||
}
|
||
|
||
.icon-action-btn {
|
||
color: #64748b;
|
||
border-radius: 6px;
|
||
transition: all 0.15s ease;
|
||
width: 28px;
|
||
height: 28px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
.icon-action-btn:hover {
|
||
background: #eff6ff;
|
||
color: #2563eb;
|
||
border-color: #dbeafe;
|
||
}
|
||
|
||
.action-placeholder-icon {
|
||
width: 28px;
|
||
height: 28px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #cbd5e1;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.table-footer {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
padding: 16px 32px 24px;
|
||
background: transparent;
|
||
}
|
||
|
||
.feedback-banner {
|
||
padding: 12px 16px;
|
||
border-radius: 10px;
|
||
background: #fff;
|
||
border: 1px solid transparent;
|
||
}
|
||
.feedback-banner.error {
|
||
border-color: #fca5a5;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
:global(.publish-error-popover .ant-popover-inner) {
|
||
max-width: min(720px, 82vw);
|
||
border: 1px solid #fecaca;
|
||
border-radius: 8px;
|
||
box-shadow: 0 18px 38px rgba(15, 23, 42, 0.16);
|
||
}
|
||
|
||
:global(.publish-error-popover .ant-popover-inner-content) {
|
||
padding: 12px;
|
||
}
|
||
|
||
:global(.publish-error-popover .ant-popover-arrow::before) {
|
||
background: #fff;
|
||
border-color: #fecaca;
|
||
}
|
||
|
||
:global(.publish-error-popover .error-popover-panel) {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
min-width: min(520px, 76vw);
|
||
max-width: min(720px, 82vw);
|
||
}
|
||
|
||
:global(.publish-error-popover .error-popover-header) {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: #dc2626;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
:global(.publish-error-popover .error-popover-header::before) {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
background: #ef4444;
|
||
content: '';
|
||
}
|
||
|
||
:global(.publish-error-popover .error-popover-content) {
|
||
max-height: 360px;
|
||
margin: 0;
|
||
overflow: auto;
|
||
padding: 10px;
|
||
border: 1px solid #fee2e2;
|
||
border-radius: 6px;
|
||
background: #fff7f7;
|
||
color: #991b1b;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
white-space: pre-wrap;
|
||
overflow-wrap: anywhere;
|
||
word-break: break-word;
|
||
}
|
||
|
||
:global(.publish-error-popover .popover-copy-btn) {
|
||
display: inline-flex;
|
||
align-self: flex-end;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
height: 28px;
|
||
padding: 0 10px;
|
||
border: 1px solid #fecaca;
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
color: #dc2626;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
transition:
|
||
background-color 0.2s ease,
|
||
border-color 0.2s ease;
|
||
}
|
||
|
||
:global(.publish-error-popover .popover-copy-btn:hover),
|
||
:global(.publish-error-popover .popover-copy-btn:focus-visible) {
|
||
background: #fef2f2;
|
||
border-color: #f87171;
|
||
outline: none;
|
||
}
|
||
|
||
/* Responsiveness */
|
||
@media (max-width: 1024px) {
|
||
.hero-header {
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
.stats-strip {
|
||
flex-wrap: wrap;
|
||
gap: 24px;
|
||
}
|
||
}
|
||
</style>
|