Files
geo/apps/desktop-client/src/renderer/views/PublishManagementView.vue
T
root 6fa9a6c052 feat(dongchedi): translate platform "server exception" into actionable copy
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.
2026-05-11 12:26:38 +08:00

1374 lines
38 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>