refactor(publish): replace inline banners with notifications and popover error preview

- Switch retry/copy/console feedback from inline banners to antd
  notifications so the table layout stops jumping on each action.
- Truncate the error message in the table cell and move full text +
  copy button into a hover popover, preventing very long adapter
  errors from blowing out the row width.
- Pin column widths and force fixed table layout so the meta column
  no longer expands the table off-screen on long error strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 12:35:03 +08:00
parent fed1e4e5ba
commit 87c902c5da
@@ -9,6 +9,7 @@ import {
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 StatusBadge from "../components/StatusBadge.vue";
@@ -18,6 +19,8 @@ import { desktopPublishMediaCatalog } from "../lib/media-catalog";
import { normalizePublisherErrorMessage } from "../../shared/publisher-errors";
const PAGE_SIZE = 10;
const INLINE_ERROR_HEAD_CHARS = 48;
const INLINE_ERROR_TAIL_CHARS = 10;
const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[number]> = Object.fromEntries(
desktopPublishMediaCatalog.map((item) => [item.id, item]),
@@ -131,6 +134,15 @@ function normalizeTaskErrorMessage(message: string | null): string | null {
return normalizePublisherErrorMessage(normalized) ?? 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"]): string {
switch (status) {
case "queued":
@@ -164,11 +176,26 @@ const searchTitle = ref("");
const appliedTitle = ref("");
const loading = ref(false);
const error = ref<string | null>(null);
const actionError = ref<string | null>(null);
const actionPendingTaskId = ref<string | null>(null);
const consolePendingTaskId = ref<string | null>(null);
const copiedErrorTaskId = ref<string | null>(null);
const actionMessage = 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;
@@ -199,15 +226,13 @@ async function refreshTasks(page = currentPage.value) {
async function retryTask(taskId: string) {
actionPendingTaskId.value = taskId;
actionError.value = null;
actionMessage.value = null;
try {
await window.desktopBridge.app.retryPublishTask(taskId);
actionMessage.value = "已重新加入发布队列";
notifySuccess("已重新加入发布队列");
await refreshTasks();
} catch (err) {
actionError.value = err instanceof Error ? err.message : "重新发送失败";
notifyActionError(err instanceof Error ? err.message : "重新发送失败");
} finally {
actionPendingTaskId.value = null;
}
@@ -217,19 +242,17 @@ async function openExternalUrl(url: string | null) {
if (!url) {
return;
}
actionError.value = null;
try {
await window.desktopBridge.app.openExternalUrl(url);
} catch (err) {
actionError.value = err instanceof Error ? err.message : "打开外链失败";
notifyActionError(err instanceof Error ? err.message : "打开外链失败");
}
}
async function openAccountWorkbench(record: PublishTaskItem) {
const account = accountMap.value.get(record.accountId) ?? null;
consolePendingTaskId.value = record.id;
actionError.value = null;
try {
await window.desktopBridge.app.openPublishAccountConsole({
@@ -239,7 +262,7 @@ async function openAccountWorkbench(record: PublishTaskItem) {
displayName: account?.displayName ?? record.accountName,
});
} catch (err) {
actionError.value = err instanceof Error ? err.message : "打开账号工作台失败";
notifyActionError(err instanceof Error ? err.message : "打开账号工作台失败");
} finally {
consolePendingTaskId.value = null;
}
@@ -267,18 +290,17 @@ async function copyErrorMessage(record: PublishTaskItem) {
return;
}
actionError.value = null;
try {
await copyTextToClipboard(record.errorMessage);
copiedErrorTaskId.value = record.id;
actionMessage.value = "错误详情已复制";
notifySuccess("错误详情已复制");
window.setTimeout(() => {
if (copiedErrorTaskId.value === record.id) {
copiedErrorTaskId.value = null;
}
}, 1_500);
} catch (err) {
actionError.value = err instanceof Error ? err.message : "复制错误详情失败";
notifyActionError(err instanceof Error ? err.message : "复制错误详情失败");
}
}
@@ -368,12 +390,12 @@ const historyTotal = computed(() => taskPage.value?.history_total ?? 0);
const totalCount = computed(() => taskPage.value?.total ?? 0);
const tableColumns = [
{ title: "文章标题", key: "title", dataIndex: "title" },
{ title: "账号 / 平台", key: "account", dataIndex: "account" },
{ title: "文章标题", key: "title", dataIndex: "title", width: 280 },
{ title: "账号 / 平台", key: "account", dataIndex: "account", width: 180 },
{ title: "状态", key: "status", dataIndex: "status", width: 130 },
{ title: "任务信息", key: "meta", dataIndex: "meta" },
{ title: "时间", key: "time", dataIndex: "time", width: 220 },
{ title: "操作", key: "actions", align: "right" as const, width: 120 },
{ title: "任务信息", key: "meta", dataIndex: "meta", width: 360 },
{ title: "时间", key: "time", dataIndex: "time", width: 210 },
{ title: "操作", key: "actions", align: "right" as const, width: 112 },
];
</script>
@@ -412,9 +434,7 @@ const tableColumns = [
</section>
<!-- Feedback Banners -->
<div v-if="actionMessage" class="feedback-banner success"><div class="success-text">{{ actionMessage }}</div></div>
<div v-if="error" class="feedback-banner error"><div class="error-text">{{ error }}</div></div>
<div v-if="actionError" class="feedback-banner error"><div class="error-text">{{ actionError }}</div></div>
<!-- Table Details Section -->
<section class="content-section">
@@ -504,22 +524,24 @@ const tableColumns = [
<div class="info-stack">
<span class="subtitle strong-text">{{ record.summary }}</span>
<span v-if="record.errorMessage" class="error-message-row">
<a-tooltip placement="topLeft" overlay-class-name="publish-error-tooltip">
<template #title>
<pre class="error-tooltip-content">{{ record.errorMessage }}</pre>
<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">{{ record.errorMessage }}</span>
</a-tooltip>
<a-tooltip :title="copiedErrorTaskId === record.id ? '已复制' : '复制错误详情'" placement="top">
<button
class="copy-error-btn"
type="button"
:aria-label="copiedErrorTaskId === record.id ? '错误详情已复制' : '复制错误详情'"
@click.stop="copyErrorMessage(record)"
>
<CopyOutlined />
</button>
</a-tooltip>
<span class="error-text error-text--truncate">{{ inlineErrorPreview(record.errorMessage) }}</span>
</a-popover>
</span>
<span class="subtitle mono-detail">
尝试 {{ record.attempts }}
@@ -727,6 +749,19 @@ const tableColumns = [
padding: 0 32px;
}
:deep(.modern-table .ant-table),
:deep(.modern-table .ant-table-container),
:deep(.modern-table .ant-table-content) {
width: 100%;
max-width: 100%;
overflow: hidden;
}
:deep(.modern-table .ant-table table) {
width: 100% !important;
table-layout: fixed !important;
}
:deep(.modern-table .ant-table-thead > tr > th) {
background-color: transparent !important;
color: #64748b;
@@ -759,11 +794,15 @@ const tableColumns = [
.cell-primary-secondary {
display: flex;
flex-direction: column;
min-width: 0;
max-width: 100%;
}
.info-stack {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
max-width: 100%;
}
.info-stack .title {
font-weight: 600;
@@ -835,16 +874,39 @@ const tableColumns = [
font-weight: 500;
}
.meta-cell {
width: 100%;
min-width: 0;
}
.error-message-row {
display: block;
width: 100%;
min-width: 0;
max-width: 100%;
margin-top: 4px;
overflow: hidden;
}
.error-text {
color: #ef4444;
font-size: 13px;
margin-top: 4px;
font-weight: 500;
}
.success-text {
color: #10b981;
font-size: 13px;
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 */
@@ -906,16 +968,97 @@ const tableColumns = [
border-color: #fca5a5;
background: #fef2f2;
}
.feedback-banner.success {
border-color: #6ee7b7;
background: #ecfdf5;
}
:global(.external-url-tooltip .ant-tooltip-inner) {
max-width: min(520px, 80vw);
overflow-wrap: anywhere;
}
: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 {