Files
geo/apps/admin-web/src/views/PublishManagementView.vue
T
root aff2c5f6ea feat(publish-dedup): disable already-published accounts and rework retry UX
In the admin publish modal, fetch the article's publish records, disable
accounts that already succeeded for the same article/platform, label
queued/publishing accounts inline, and route the success notification by
created vs existing task counts (with a dedicated warning for the
`desktop_publish_duplicate` server error).

Replace the "再次发送" mutation/popconfirm on both admin and desktop publish
management views with a "重新创建发布任务" action that calls the same Create
endpoint, only enables on failed/aborted/unknown rows, and reports created
vs existing task counts (with a tailored warning for `unknown` results
that may have already succeeded).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:43:42 +08:00

1122 lines
33 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,
RetweetOutlined,
SearchOutlined,
} from '@ant-design/icons-vue'
import type { DesktopTaskInfo, JsonValue, TenantPublishTaskListResponse } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { publishTasksApi, tenantAccountsApi } from '@/lib/api'
import { copyTextToClipboard } from '@/lib/clipboard'
import { formatDateTime } from '@/lib/display'
import { formatError } from '@/lib/errors'
import { resolvePlatformLogoUrl } from '@/lib/publish-account-cards'
import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms'
import { useCompanyStore } from '@/stores/company'
const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 52
const INLINE_ERROR_TAIL_CHARS = 10
const ACTIVE_PUBLISH_TASK_POLL_INTERVAL_MS = 5_000
const DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE =
'懂车帝平台返回异常,请打开懂车帝后台查看并处理平台提示,确认账号状态后再重试。'
type PublishTaskStatus = DesktopTaskInfo['status']
interface PublishTaskItem {
id: string
title: string
articleId: number | null
platform: string
platformName: string
platformShortName: string
platformAccent: string
platformLogoUrl: string | null
accountId: string
accountName: string
platformUid: string
status: PublishTaskStatus
statusLabel: string
statusColor: string
summary: string
attempts: number
leaseExpiresAt: string | null
createdAt: string
updatedAt: string
externalUrl: string | null
workbenchUrl: string
publishType: 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 {
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 normalizePublishTaskStatus(status: PublishTaskStatus): PublishTaskStatus {
return status === 'unknown' ? 'failed' : status
}
function isPendingStatus(status: PublishTaskStatus): boolean {
return status === 'queued' || status === 'in_progress'
}
function hasActivePublishingTask(page: TenantPublishTaskListResponse | undefined): boolean {
return Boolean(page?.items.some((task) => normalizePublishTaskStatus(task.status) === 'in_progress'))
}
function normalizeTaskErrorMessage(messageText: string | null, platform?: string | null): string | null {
const normalized = extractString(messageText)
if (!normalized) {
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) ||
/\bdongchedi_platform_exception\b/i.test(normalized))
) {
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
}
}
function inlineErrorPreview(messageText: string): string {
const compact = messageText.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: PublishTaskStatus,
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':
return publishType === 'draft'
? '文章已保存到草稿箱,需在平台后台完成发表。'
: '文章已成功发送到目标平台。'
case 'failed':
return '本次发送失败,请检查账号状态或平台提示后重新创建发布。'
case 'aborted':
return '任务已被取消,请检查原因后重新创建发布。'
case 'unknown':
default:
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' }
}
switch (status) {
case 'queued':
return { label: '等待发布', color: 'processing' }
case 'in_progress':
return { label: '正在发布', color: 'processing' }
case 'succeeded':
return { label: '发送成功', color: 'success' }
case 'failed':
case 'unknown':
return { label: '发送失败', color: 'error' }
case 'aborted':
return { label: '已取消', color: 'default' }
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
}
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
} catch {
return false
}
}
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,
display_name: record.accountName,
})
return `shengxintui://desktop/workbench?${params.toString()}`
}
const { t } = useI18n()
const companyStore = useCompanyStore()
const page = ref(1)
const searchTitle = ref('')
const appliedTitle = ref('')
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({
queryKey: computed(() => [
'tenant',
'publish-tasks',
companyStore.currentBrandId,
{ page: page.value, page_size: PAGE_SIZE, title: appliedTitle.value },
]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
queryFn: () =>
publishTasksApi.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
},
refetchIntervalInBackground: false,
})
const loading = computed(() => tasksQuery.isPending.value || accountsQuery.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 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 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 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))
return {
id: task.id,
title: extractString(payload.title) ?? `发布任务 · ${platformMeta.name}`,
articleId,
platform: platformId,
platformName: 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,
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),
}
}),
)
const columns = computed<TableColumnsType<PublishTaskItem>>(() => [
{ title: '发文标题', key: 'title', dataIndex: 'title', width: 280, ellipsis: true },
{ title: '账号 / 平台', key: 'account', dataIndex: 'account', width: 190 },
{ title: '状态', key: 'status', dataIndex: 'status', width: 110 },
{ title: '任务信息', key: 'meta', dataIndex: 'meta', width: 320 },
{ title: '时间', key: 'time', dataIndex: 'time', width: 170 },
{ title: '操作', key: 'actions', align: 'right', width: 150, fixed: 'right' },
])
watch(searchTitle, (value) => {
if (!value.trim() && appliedTitle.value) {
appliedTitle.value = ''
page.value = 1
}
})
async function refresh(): Promise<void> {
if (manualRefreshing.value) {
return
}
manualRefreshing.value = true
try {
await Promise.all([tasksQuery.refetch(), accountsQuery.refetch()])
} finally {
manualRefreshing.value = false
}
}
function applySearch(): void {
page.value = 1
appliedTitle.value = searchTitle.value.trim()
}
function handlePageChange(nextPage: number): void {
page.value = nextPage
}
function rowClassName(record: PublishTaskItem): string {
return isPendingStatus(record.status) ? 'publish-management-row--pending' : ''
}
function canRetryTask(record: PublishTaskItem): boolean {
return record.status === 'failed' || record.status === 'aborted' || record.status === 'unknown'
}
async function retryTask(record: PublishTaskItem): Promise<void> {
retryingTaskId.value = record.id
try {
const result = await publishTasksApi.retry(record.id)
const created = result.created_task_ids.length
const existing = result.existing_task_ids.length
if (created > 0) {
message.success(created === 1 ? '已重新创建发布任务' : `已重新创建 ${created} 个发布任务`)
} else if (existing > 0) {
if (record.status === 'unknown') {
message.warning('原任务结果未知且已有发布记录,可能已成功,请先核实平台侧结果。')
} else {
message.info('发布任务已存在,无需重复创建')
}
} else {
message.success('已提交重试请求')
}
await tasksQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
retryingTaskId.value = null
}
}
async function copyErrorMessage(record: PublishTaskItem): Promise<void> {
if (!record.errorMessage) {
return
}
try {
await copyTextToClipboard(record.errorMessage)
copiedErrorTaskId.value = record.id
message.success('错误详情已复制')
window.setTimeout(() => {
if (copiedErrorTaskId.value === record.id) {
copiedErrorTaskId.value = null
}
}, 1500)
} catch (error) {
message.error(formatError(error))
}
}
</script>
<template>
<div class="publish-management-view">
<section class="publish-management-view__top-card">
<div class="publish-management-view__header">
<div class="publish-management-view__header-title">
<h2>{{ t('route.publishManagement.title') }}</h2>
<p>{{ t('route.publishManagement.description') }}</p>
</div>
<div class="publish-management-view__header-actions">
<a-button :loading="refreshing" @click="refresh">
<template #icon><ReloadOutlined /></template>
{{ t('common.refresh') }}
</a-button>
</div>
</div>
<div class="publish-management-view__summary">
<div class="summary-chip">
<span>等待发布</span>
<strong :class="{ 'summary-chip__value--accent': pendingCount > 0 }">
{{ pendingCount }}
</strong>
</div>
<div class="summary-chip">
<span>已发送</span>
<strong>{{ historyTotal }}</strong>
</div>
<div class="summary-chip">
<span>历史总数</span>
<strong>{{ totalCount }}</strong>
</div>
</div>
</section>
<section class="publish-management-view__table-card">
<div class="publish-management-view__toolbar">
<div>
<p class="eyebrow">Publish Queue</p>
<h3>待发布与已发送记录</h3>
</div>
<div class="publish-management-view__filters">
<a-input
v-model:value="searchTitle"
allow-clear
placeholder="搜索发文标题"
class="publish-management-view__search"
@pressEnter="applySearch"
>
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<a-button type="primary" @click="applySearch">{{ t('common.search') }}</a-button>
</div>
</div>
<a-table
row-key="id"
class="publish-management-table"
:columns="columns"
:data-source="publishTasks"
:pagination="false"
:loading="loading"
:scroll="{ x: 1220 }"
:row-class-name="rowClassName"
:locale="{ emptyText: '暂无匹配的发文任务' }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="publish-cell-stack">
<a-tooltip :title="record.title" placement="topLeft">
<span class="publish-cell-title">{{ record.title }}</span>
</a-tooltip>
<span class="publish-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="publish-account-cell">
<span class="platform-avatar" :style="{ color: record.platformAccent }">
<img
v-if="record.platformLogoUrl"
:src="record.platformLogoUrl"
:alt="record.platformName"
/>
<template v-else>{{ record.platformShortName }}</template>
</span>
<div class="publish-cell-stack">
<span class="publish-cell-title publish-cell-title--compact">
{{ record.accountName }}
</span>
<span class="publish-cell-sub">{{ record.platformName }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.statusColor">{{ record.statusLabel }}</a-tag>
</template>
<template v-else-if="column.key === 'meta'">
<div class="publish-cell-stack publish-meta-cell">
<span v-if="record.errorMessage" class="publish-error-row">
<a-popover placement="topLeft" overlay-class-name="publish-error-popover">
<template #content>
<div class="publish-error-panel" @click.stop>
<div class="publish-error-panel__title">发布错误</div>
<pre>{{ record.errorMessage }}</pre>
<button
type="button"
class="publish-error-panel__copy"
@click.stop="copyErrorMessage(record)"
>
<CopyOutlined />
<span>
{{ copiedErrorTaskId === record.id ? '已复制' : '复制错误详情' }}
</span>
</button>
</div>
</template>
<span class="publish-error-text">
{{ inlineErrorPreview(record.errorMessage) }}
</span>
</a-popover>
</span>
<span v-else class="publish-cell-sub publish-cell-sub--strong">
{{ record.summary }}
</span>
<span class="publish-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>租约至 {{ formatDateTime(record.leaseExpiresAt, 'HH:mm') }}</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="publish-cell-stack">
<span class="publish-cell-title publish-cell-title--compact">
{{ formatDateTime(record.updatedAt) }}
</span>
<span class="publish-cell-sub">创建 {{ formatDateTime(record.createdAt) }}</span>
</div>
</a-tooltip>
</template>
<template v-else-if="column.key === 'actions'">
<div class="publish-actions">
<a-tooltip :title="record.externalUrl ? '打开外链' : '无外链'" placement="top">
<span class="publish-action-tooltip-wrap">
<a-button
type="text"
class="publish-action-btn"
:disabled="!record.externalUrl"
:href="record.externalUrl || undefined"
:target="record.externalUrl ? '_blank' : undefined"
:rel="record.externalUrl ? 'noopener noreferrer' : undefined"
:aria-label="
record.externalUrl ? `打开外链${record.title}` : `无外链${record.title}`
"
>
<template #icon><ExportOutlined /></template>
</a-button>
</span>
</a-tooltip>
<a-tooltip
:title="`打开 ${record.accountName} 的${record.platformName}工作台`"
placement="top"
>
<a-button
type="text"
class="publish-action-btn"
:href="record.workbenchUrl"
:disabled="!record.canOpenWorkbench"
:aria-label="`打开 ${record.accountName} ${record.platformName}工作台`"
>
<template #icon><DesktopOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canRetryTask(record)" title="重新创建发布任务" placement="top">
<a-button
type="text"
class="publish-action-btn"
:loading="retryingTaskId === record.id"
:aria-label="`重新创建发布任务:${record.title}`"
@click.stop="retryTask(record)"
>
<template #icon><RetweetOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isPendingStatus(record.status)" title="发布任务进行中">
<span class="publish-action-placeholder">
<ClockCircleOutlined />
</span>
</a-tooltip>
</div>
</template>
</template>
</a-table>
<div class="publish-management-view__pagination">
<a-pagination
:current="page"
:page-size="PAGE_SIZE"
:total="totalCount"
:show-size-changer="false"
@change="handlePageChange"
/>
</div>
</section>
</div>
</template>
<style scoped>
.publish-management-view {
display: flex;
flex-direction: column;
gap: 20px;
}
.publish-management-view__top-card,
.publish-management-view__table-card {
background: #fff;
border: 1px solid #e6edf5;
border-radius: 12px;
overflow: hidden;
}
.publish-management-view__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
padding: 24px;
}
.publish-management-view__header-title h2 {
margin: 0;
color: #1a1a1a;
font-size: 20px;
font-weight: 600;
line-height: 1.4;
}
.publish-management-view__header-title p {
margin: 6px 0 0;
color: #8c8c8c;
font-size: 13px;
line-height: 1.7;
}
.publish-management-view__summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid #eef2f7;
}
.summary-chip {
padding: 18px 24px;
border-right: 1px solid #eef2f7;
}
.summary-chip:last-child {
border-right: none;
}
.summary-chip span {
display: block;
color: #8c8c8c;
font-size: 12px;
}
.summary-chip strong {
display: block;
margin-top: 8px;
color: #1a1a1a;
font-size: 24px;
font-weight: 700;
}
.summary-chip__value--accent {
color: #d48806 !important;
}
.publish-management-view__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px;
border-bottom: 1px solid #f0f0f0;
}
.publish-management-view__toolbar h3 {
margin: 0;
color: #1a1a1a;
font-size: 16px;
font-weight: 700;
}
.publish-management-view__filters {
display: flex;
align-items: center;
gap: 12px;
}
.publish-management-view__search {
width: 280px;
}
.publish-management-table {
padding: 0 24px 12px;
}
:deep(.publish-management-table .ant-table-thead > tr > th) {
color: #6b7280;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
:deep(.publish-management-table .ant-table-tbody > tr > td) {
padding: 15px 12px;
vertical-align: middle;
}
:deep(.publish-management-table .publish-management-row--pending > td) {
background: #fffbeb;
}
.publish-cell-stack {
display: flex;
min-width: 0;
max-width: 100%;
flex-direction: column;
gap: 2px;
}
.publish-cell-title {
display: block;
overflow: hidden;
color: #1f2937;
font-size: 14px;
font-weight: 600;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.publish-cell-title--compact {
font-size: 13px;
}
.publish-cell-sub {
display: block;
overflow: hidden;
margin-top: 3px;
color: #8c8c8c;
font-size: 12px;
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
}
.publish-cell-sub--strong {
color: #475569;
font-weight: 500;
}
.publish-account-cell {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.platform-avatar {
display: inline-flex;
overflow: hidden;
width: 28px;
height: 28px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border: 1px solid #e6edf5;
border-radius: 8px;
background: #f7f9fc;
font-size: 12px;
font-weight: 800;
}
.platform-avatar img {
width: 20px;
height: 20px;
object-fit: contain;
}
.publish-meta-cell {
width: 100%;
}
.publish-meta-footer {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
color: #94a3b8;
font-size: 11.5px;
line-height: 1.6;
}
.attempt-chip {
display: inline-flex;
height: 18px;
align-items: center;
padding: 0 6px;
border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
font-weight: 600;
}
.meta-divider {
color: #cbd5e1;
}
.publish-error-row {
display: block;
width: 100%;
min-width: 0;
}
.publish-error-text {
display: block;
overflow: hidden;
max-width: 100%;
color: #dc2626;
font-size: 12.5px;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.publish-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
}
.publish-action-btn {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 7px;
color: #64748b;
}
.publish-action-btn:hover:not(:disabled) {
background: #eff6ff;
color: #1677ff;
}
.publish-action-tooltip-wrap {
display: inline-flex;
}
.publish-action-placeholder {
display: inline-flex;
width: 30px;
height: 30px;
align-items: center;
justify-content: center;
color: #cbd5e1;
}
.publish-management-view__pagination {
display: flex;
justify-content: flex-end;
padding: 16px 24px 24px;
}
: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-panel) {
display: flex;
min-width: min(520px, 76vw);
max-width: min(720px, 82vw);
flex-direction: column;
gap: 10px;
}
:global(.publish-error-panel__title) {
color: #dc2626;
font-size: 13px;
font-weight: 700;
}
:global(.publish-error-panel pre) {
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;
word-break: break-word;
}
:global(.publish-error-panel__copy) {
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;
}
@media (max-width: 900px) {
.publish-management-view__header,
.publish-management-view__toolbar {
flex-direction: column;
align-items: stretch;
}
.publish-management-view__summary {
grid-template-columns: 1fr;
}
.summary-chip {
border-right: none;
border-bottom: 1px solid #eef2f7;
}
.summary-chip:last-child {
border-bottom: none;
}
.publish-management-view__filters {
flex-direction: column;
align-items: stretch;
}
.publish-management-view__search {
width: 100%;
}
}
</style>