Files
geo/apps/admin-web/src/views/PublishManagementView.vue
T
root bdfe79c921
Frontend CI / Frontend (push) Successful in 3m15s
Backend CI / Backend (push) Successful in 14m44s
Preserve publish record history
2026-06-25 01:52:26 +08:00

1032 lines
29 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,
StopOutlined,
} from '@ant-design/icons-vue'
import type { PublishRecord, PublishRecordListResponse } from '@geo/shared-types'
import { useQuery } from '@tanstack/vue-query'
import type { TableColumnsType } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { publishRecordsApi, publishTasksApi } from '@/lib/api'
import { copyTextToClipboard } from '@/lib/clipboard'
import { formatDateTime } from '@/lib/display'
import { formatError, formatStoredErrorMessage } from '@/lib/errors'
import { resolvePlatformLogoUrl } from '@/lib/publish-account-cards'
import { getPublishPlatformMeta, normalizePublishPlatformId } from '@/lib/publish-platforms'
const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 52
const INLINE_ERROR_TAIL_CHARS = 10
const ACTIVE_PUBLISH_RECORD_POLL_INTERVAL_MS = 5_000
const DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE =
'懂车帝平台返回异常,请打开懂车帝后台查看并处理平台提示,确认账号状态后再重试。'
interface PublishRecordItem {
id: string
recordId: number
title: string
articleId: number
brandId: number
brandName: string
brandDeleted: boolean
targetType: string
platform: string
platformName: string
platformShortName: string
platformAccent: string
platformLogoUrl: string | null
accountName: string
status: string
statusLabel: string
statusColor: string
summary: string
createdAt: string
updatedAt: string
publishedAt: string | null
externalUrl: string | null
workbenchUrl: string | null
desktopTaskId: string | null
errorMessage: string | null
canOpenWorkbench: boolean
}
function extractString(value: string | null | undefined): string | null {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed ? trimmed : null
}
function normalizeRecordStatus(status: string): string {
const normalized = status.trim().toLowerCase()
if (normalized === 'published' || normalized === 'publish_success') {
return 'success'
}
if (normalized === 'canceled') {
return 'cancelled'
}
return normalized || 'unknown'
}
function isPendingStatus(status: string): boolean {
return status === 'queued' || status === 'publishing'
}
function hasActivePublishingRecord(page: PublishRecordListResponse | undefined): boolean {
return Boolean(
page?.items.some((record) => isPendingStatus(normalizeRecordStatus(record.status))),
)
}
function normalizeRecordErrorMessage(
messageText: string | null,
platform?: string | null,
): string | null {
const normalized = extractString(messageText)
if (!normalized) {
return null
}
if (
platform === 'dongchedi' &&
(/\bserver[\s_-]*exception\b/i.test(normalized) ||
/\bdongchedi_platform_exception\b/i.test(normalized))
) {
return DONGCHEDI_PLATFORM_EXCEPTION_MESSAGE
}
return formatStoredErrorMessage(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 summaryForRecord(record: PublishRecord, status: string): string {
const targetLabel = record.target_type === 'enterprise_site' ? '企业站点' : '目标平台'
switch (status) {
case 'queued':
return record.target_type === 'enterprise_site'
? '企业站发布请求已创建,等待服务端处理。'
: '任务已进入桌面端发布队列。'
case 'publishing':
return record.target_type === 'enterprise_site'
? '服务端正在调用站点连接发文。'
: '桌面端正在执行发送。'
case 'success':
return `文章已成功发送到${targetLabel}`
case 'failed':
return record.target_type === 'enterprise_site'
? '本次企业站发布失败,请检查站点连接或发布错误。'
: '本次发送失败,请检查账号状态或平台提示后重新提交。'
case 'cancelled':
return '本次发布已取消。'
case 'unknown':
default:
return '发布结果异常,请检查目标侧结果。'
}
}
function statusMetaForRecord(status: string): { label: string; color: string } {
switch (status) {
case 'queued':
return { label: '等待发布', color: 'processing' }
case 'publishing':
return { label: '发布中', color: 'processing' }
case 'success':
return { label: '发布成功', color: 'success' }
case 'failed':
return { label: '发布失败', color: 'error' }
case 'cancelled':
return { label: '已取消', color: 'default' }
case 'unknown':
return { label: '结果异常', color: 'error' }
default:
return { label: status, color: 'default' }
}
}
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
accountName: string
}): string {
const params = new URLSearchParams({
action: 'open-publish-account-console',
account_id: record.accountId,
platform: record.platform,
platform_uid: '',
display_name: record.accountName,
})
return `shengxintui://desktop/workbench?${params.toString()}`
}
const { t } = useI18n()
const page = ref(1)
const searchTitle = ref('')
const appliedTitle = ref('')
const copiedErrorTaskId = ref<string | null>(null)
const retryingTaskId = ref<string | null>(null)
const cancelingTaskId = ref<string | null>(null)
const manualRefreshing = ref(false)
const recordsQuery = useQuery({
queryKey: computed(() => [
'tenant',
'publish-records',
{ page: page.value, page_size: PAGE_SIZE, title: appliedTitle.value },
]),
queryFn: () =>
publishRecordsApi.list({
page: page.value,
page_size: PAGE_SIZE,
title: appliedTitle.value || undefined,
}),
refetchInterval: (query) => {
const data = query.state.data as PublishRecordListResponse | undefined
return hasActivePublishingRecord(data) ? ACTIVE_PUBLISH_RECORD_POLL_INTERVAL_MS : false
},
refetchIntervalInBackground: false,
})
const loading = computed(() => recordsQuery.isPending.value)
const refreshing = computed(() => manualRefreshing.value)
const recordPage = computed(() => recordsQuery.data.value)
const pendingCount = computed(() => recordPage.value?.pending_count ?? 0)
const historyTotal = computed(() => recordPage.value?.history_total ?? 0)
const totalCount = computed(() => recordPage.value?.total ?? 0)
const publishRecords = computed<PublishRecordItem[]>(() =>
(recordPage.value?.items ?? []).map((record) => {
const status = normalizeRecordStatus(record.status)
const platformId = normalizePublishPlatformId(record.platform_id)
const platformMeta = getPublishPlatformMeta(platformId)
const isEnterpriseSite = record.target_type === 'enterprise_site'
const accountName =
extractString(record.platform_nickname) ?? (isEnterpriseSite ? '企业自建站点' : '待同步账号')
const statusMeta = statusMetaForRecord(status)
const externalUrl =
extractString(record.external_article_url) ?? extractString(record.external_manage_url)
const desktopAccountId = extractString(record.desktop_account_id)
const workbenchUrl =
!isEnterpriseSite && desktopAccountId
? buildDesktopWorkbenchUrl({
accountId: desktopAccountId,
platform: platformId,
accountName,
})
: null
return {
id: String(record.id),
recordId: record.id,
title: extractString(record.article_title) ?? `文章 #${record.article_id}`,
articleId: record.article_id,
brandId: record.brand_id,
brandName: extractString(record.brand_name) ?? `公司 #${record.brand_id}`,
brandDeleted: Boolean(record.brand_deleted),
targetType: record.target_type ?? 'platform_account',
platform: platformId,
platformName: isEnterpriseSite ? '企业自建站点' : record.platform_name || platformMeta.name,
platformShortName: platformMeta.shortName,
platformAccent: platformMeta.accent,
platformLogoUrl: resolvePlatformLogoUrl(platformId),
accountName,
status,
statusLabel: statusMeta.label,
statusColor: statusMeta.color,
summary: summaryForRecord(record, status),
createdAt: record.created_at,
updatedAt: record.updated_at,
publishedAt: record.published_at,
externalUrl: isSafeHttpUrl(externalUrl) ? externalUrl : null,
workbenchUrl,
desktopTaskId: extractString(record.desktop_task_id),
errorMessage: normalizeRecordErrorMessage(record.error_message, platformId),
canOpenWorkbench: Boolean(workbenchUrl),
}
}),
)
const columns = computed<TableColumnsType<PublishRecordItem>>(() => [
{ 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 recordsQuery.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: PublishRecordItem): string {
return isPendingStatus(record.status) ? 'publish-management-row--pending' : ''
}
function canRetryTask(record: PublishRecordItem): boolean {
return Boolean(record.desktopTaskId && record.status === 'failed' && !record.brandDeleted)
}
function canCancelTask(record: PublishRecordItem): boolean {
return Boolean(record.desktopTaskId && record.status === 'queued')
}
async function retryTask(record: PublishRecordItem): Promise<void> {
if (!record.desktopTaskId) {
return
}
retryingTaskId.value = record.id
try {
const result = await publishTasksApi.retry(record.desktopTaskId)
const created = result.created_task_ids.length
const existing = result.existing_task_ids.length
const requeued = result.requeued_task_ids?.length ?? 0
if (created > 0) {
message.success(created === 1 ? '已创建新的发布任务' : `已创建 ${created} 个新的发布任务`)
} else if (requeued > 0) {
message.success(requeued === 1 ? '已重新提交发布任务' : `已重新提交 ${requeued} 个发布任务`)
} else if (existing > 0) {
if (record.status === 'unknown') {
message.warning('原任务结果未知且已有发布记录,可能已成功,请先核实平台侧结果。')
} else {
message.info('发布任务已存在,无需重复创建')
}
} else {
message.success('已提交重试请求')
}
await recordsQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
retryingTaskId.value = null
}
}
async function cancelTask(record: PublishRecordItem): Promise<void> {
if (!record.desktopTaskId) {
return
}
cancelingTaskId.value = record.id
try {
await publishTasksApi.cancel(record.desktopTaskId)
message.success('已取消等待发布任务')
await recordsQuery.refetch()
} catch (error) {
message.error(formatError(error))
} finally {
cancelingTaskId.value = null
}
}
async function copyErrorMessage(record: PublishRecordItem): 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 Records</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="publishRecords"
: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>
<span class="publish-brand-line">
<span class="publish-brand-name">{{ record.brandName }}</span>
<a-tag v-if="record.brandDeleted" color="default" class="publish-brand-tag">
公司已删除
</a-tag>
</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.recordId }}</span>
<span class="meta-divider">·</span>
<span>{{ record.targetType === 'enterprise_site' ? '企业站点' : '媒体账号' }}</span>
<template v-if="record.publishedAt">
<span class="meta-divider">·</span>
<span>{{ formatDateTime(record.publishedAt) }} 发布</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
v-if="record.canOpenWorkbench"
:title="`打开 ${record.accountName} 的${record.platformName}工作台`"
placement="top"
>
<a-button
type="text"
class="publish-action-btn"
:href="record.workbenchUrl || undefined"
: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-popconfirm
v-if="canCancelTask(record)"
title="确认取消这个等待发布任务?"
ok-text="取消发布"
cancel-text="保留"
placement="topRight"
@confirm="cancelTask(record)"
>
<a-tooltip title="取消发布任务" placement="top">
<a-button
type="text"
class="publish-action-btn publish-action-btn--danger"
:loading="cancelingTaskId === record.id"
:aria-label="`取消发布任务:${record.title}`"
@click.stop
>
<template #icon><StopOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
<a-tooltip v-else-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-brand-line {
display: inline-flex;
min-width: 0;
max-width: 100%;
align-items: center;
gap: 6px;
margin-top: 3px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.publish-brand-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.publish-brand-tag {
flex: 0 0 auto;
margin-inline-end: 0;
font-size: 11px;
}
.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-btn--danger:hover:not(:disabled) {
background: #fff1f0;
color: #cf1322;
}
.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>