Files
geo/apps/admin-web/src/views/PublishManagementView.vue
T
root a44ed21967 feat(tenant): add brand publish-records list and harden enterprise site management
- Add GET /api/tenant/publish-records: brand-scoped paginated list that merges
  in-flight (queued/publishing) records ahead of history (success/failed/cancelled),
  enriched with article title and latest desktop task id; wire up
  service/handler/router/swagger/router_test
- Rework PublishManagementView to consume publish-records instead of desktop
  publish tasks; add publishRecordsApi + shared-types
  (ListPublishRecordsParams, PublishRecordListResponse)
- Enterprise site Update: distinguish explicit null from missing PATCH fields via
  EnterpriseSitePatchString, dedup site_url before write, verify RowsAffected,
  and map unique-constraint violations to a clear conflict
- MediaView: enterprise site edit/delete flows with favicon fallback handling
- Localize enterprise site / PBootCMS error messages to Chinese across the
  backend and the admin-web error map, plus legacy raw-message translation
- deploy: gate migration-coupled service rollouts behind RUN_MIGRATIONS unless
  ALLOW_SKIP_MIGRATIONS_FOR_APP_ROLLOUT=true; handle empty SERVICES safely

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 11:40:46 +08:00

945 lines
27 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 { 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'
import { useCompanyStore } from '@/stores/company'
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
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 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 recordsQuery = useQuery({
queryKey: computed(() => [
'tenant',
'publish-records',
companyStore.currentBrandId,
{ page: page.value, page_size: PAGE_SIZE, title: appliedTitle.value },
]),
enabled: computed(() => Boolean(companyStore.currentBrandId)),
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,
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')
}
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 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>
</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-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>