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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user