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