Files
geo/apps/desktop-client/src/renderer/views/PublishManagementView.vue
T
root dc68ad044c refactor(publish): collapse pending_review/unknown task states into failed
Treat ambiguous terminal states as failures end-to-end: server
normalizes unknown desktop task completions and views to failed, drops
the pending_review aggregate branch, and remaps pending_review to
failed in publish record conversion. admin-web removes the
pending_review status option from article list filters, pushes
pending_review through the failed tone, and folds lingering
publishing/pending entries under the publishing bucket. desktop-client
relabels unknown as 发送失败 in PublishManagement/TasksView, rewrites
the scaffold adapter result to a proper failed error envelope, and
sanitises legacy scaffold-only error messages to a user-friendly note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 11:44:47 +08:00

638 lines
17 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 { ReloadOutlined, SearchOutlined } from "@ant-design/icons-vue";
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from "@geo/shared-types";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import StatusBadge from "../components/StatusBadge.vue";
import SurfaceCard from "../components/SurfaceCard.vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { formatDateTime, formatRelativeTime, titleCaseToken } from "../lib/formatters";
const PAGE_SIZE = 10;
interface PublishTaskItem {
id: string;
title: string;
articleId: number | null;
platform: string;
accountId: string;
accountName: string;
status: DesktopTaskInfo["status"];
summary: string;
attempts: number;
leaseExpiresAt: number | null;
createdAt: number;
updatedAt: number;
externalArticleUrl: string | null;
externalManageUrl: string | null;
errorMessage: string | null;
}
let refreshTimer: ReturnType<typeof setInterval> | null = null;
function translatePlatform(platform: string) {
const map: Record<string, string> = {
toutiaohao: "头条号",
bilibili: "哔哩哔哩",
xiaohongshu: "小红书",
douyin: "抖音",
kuaishou: "快手",
wechat_oa: "微信公众号",
baijiahao: "百家号",
zhihu: "知乎",
};
return map[platform?.toLowerCase()] || titleCaseToken(platform);
}
function statusLabel(status: DesktopTaskInfo["status"]): string {
const map: Record<DesktopTaskInfo["status"], string> = {
queued: "等待发布",
in_progress: "正在发送",
succeeded: "发送成功",
failed: "发送失败",
unknown: "发送失败",
aborted: "已取消",
};
return map[status];
}
function isPendingStatus(status: DesktopTaskInfo["status"]): boolean {
return status === "queued" || status === "in_progress";
}
function statusTone(status: DesktopTaskInfo["status"]): "info" | "warn" | "success" | "danger" {
switch (status) {
case "queued":
case "in_progress":
return "warn";
case "succeeded":
return "success";
case "failed":
case "unknown":
case "aborted":
default:
return "danger";
}
}
function extractNumber(value: JsonValue | null | undefined): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string" && /^\d+$/.test(value.trim())) {
return Number.parseInt(value.trim(), 10);
}
return null;
}
function extractString(value: JsonValue | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function normalizePublishTaskStatus(status: DesktopTaskInfo["status"]): DesktopTaskInfo["status"] {
return status === "unknown" ? "failed" : status;
}
function normalizeTaskErrorMessage(message: string | null): string | null {
const normalized = extractString(message);
if (!normalized) {
return null;
}
if (normalized.includes("scaffold-only") || normalized.includes("requires later reconcile")) {
return "当前平台的桌面发布适配器未实现,任务已按失败处理。";
}
if (normalized.includes("adapter is not implemented for this platform")) {
return "当前平台的桌面发布适配器未实现,任务已按失败处理。";
}
return normalized;
}
function summaryForTask(status: DesktopTaskInfo["status"]): string {
switch (status) {
case "queued":
return "任务已进入队列,等前面的 PublishTasks 翻完后再继续展示历史记录。";
case "in_progress":
return "当前正在由桌面端执行发送,请避免重复触发。";
case "succeeded":
return "文章已成功发送到目标平台。";
case "failed":
return "本次发送失败,可以手动再次发送。";
case "aborted":
return "任务已被取消,如需继续可再次发送。";
case "unknown":
default:
return "发送结果异常,已按失败处理,可检查平台侧结果后再次发送。";
}
}
function parseTimestamp(value: string | null): number {
if (!value) {
return Date.now();
}
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? Date.now() : timestamp;
}
const { snapshot } = useDesktopRuntime();
const taskPage = ref<DesktopPublishTaskListResponse | null>(null);
const currentPage = ref(1);
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 actionMessage = ref<string | null>(null);
async function refreshTasks(page = currentPage.value) {
loading.value = true;
error.value = null;
try {
const response = await window.desktopBridge.app.listPublishTasks({
page,
page_size: PAGE_SIZE,
title: appliedTitle.value || undefined,
});
const lastPage = response.total > 0 ? Math.ceil(response.total / PAGE_SIZE) : 1;
if (response.total > 0 && page > lastPage) {
currentPage.value = lastPage;
await refreshTasks(lastPage);
return;
}
taskPage.value = response;
currentPage.value = response.page;
} catch (err) {
error.value = err instanceof Error ? err.message : "desktop publish tasks unavailable";
} finally {
loading.value = false;
}
}
async function retryTask(taskId: string) {
actionPendingTaskId.value = taskId;
actionError.value = null;
actionMessage.value = null;
try {
await window.desktopBridge.app.retryPublishTask(taskId);
actionMessage.value = "已重新加入发布队列。";
await refreshTasks();
} catch (err) {
actionError.value = err instanceof Error ? err.message : "重新发送失败";
} finally {
actionPendingTaskId.value = null;
}
}
function applySearch() {
currentPage.value = 1;
appliedTitle.value = searchTitle.value.trim();
void refreshTasks(1);
}
function handlePageChange(page: number) {
currentPage.value = page;
void refreshTasks(page);
}
function rowClassName(record: PublishTaskItem) {
return isPendingStatus(record.status) ? "publish-row--pending" : "";
}
watch(searchTitle, (value) => {
if (!value.trim() && appliedTitle.value) {
appliedTitle.value = "";
currentPage.value = 1;
void refreshTasks(1);
}
});
onMounted(() => {
void refreshTasks();
refreshTimer = setInterval(() => {
void refreshTasks();
}, 20_000);
});
onUnmounted(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
});
const accountNameMap = computed(() => {
const entries = snapshot.value?.accounts ?? [];
return new Map(entries.map((item) => [item.id, item.displayName]));
});
const publishTasks = computed<PublishTaskItem[]>(() =>
(taskPage.value?.items ?? []).map((task) => {
const normalizedStatus = normalizePublishTaskStatus(task.status);
const payload = task.payload ?? {};
const result = task.result ?? {};
const taskError = task.error ?? {};
const nestedContentRef =
payload.content_ref && typeof payload.content_ref === "object" && !Array.isArray(payload.content_ref)
? (payload.content_ref as Record<string, JsonValue>)
: null;
const articleId = extractNumber(payload.article_id) ?? extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id);
return {
id: task.id,
title: extractString(payload.title) ?? `发布任务 · ${translatePlatform(task.platform)}`,
articleId,
platform: task.platform,
accountId: task.target_account_id,
accountName: accountNameMap.value.get(task.target_account_id) ?? "待同步账号",
status: normalizedStatus,
summary: summaryForTask(normalizedStatus),
attempts: task.attempts,
leaseExpiresAt: task.lease_expires_at ? parseTimestamp(task.lease_expires_at) : null,
createdAt: parseTimestamp(task.created_at),
updatedAt: parseTimestamp(task.updated_at),
externalArticleUrl: extractString(result.external_article_url),
externalManageUrl: extractString(result.external_manage_url),
errorMessage: normalizeTaskErrorMessage(
extractString(taskError.message)
?? extractString(taskError.detail)
?? extractString(taskError.code),
),
};
}),
);
const pendingCount = computed(() => taskPage.value?.pending_count ?? 0);
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: "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 },
];
</script>
<template>
<section class="page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">PUBLISH EXECUTION DESK</p>
<h2>发布管理</h2>
<p class="hero-summary">
统一用一个 table 查看当前设备负责的待发布队列与已发送历史页面固定每页 10 待发布任务会始终排在最前面只有当前页的
PublishTasks 展示完后才会继续补已发送历史记录
</p>
</div>
</header>
<SurfaceCard
eyebrow="Publish Tasks"
title="待发布与已发送记录"
description="支持按文章标题搜索,分页与总数以服务端返回的 PublishTasks 结果为准。"
>
<template #action>
<a-button :loading="loading" @click="refreshTasks()">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</template>
<div class="table-toolbar">
<div class="toolbar-stats">
<span class="stat-chip">总数 {{ totalCount }}</span>
<span class="stat-chip">等待发布 {{ pendingCount }}</span>
<span class="stat-chip">已发送 {{ historyTotal }}</span>
</div>
<div class="toolbar-search">
<a-input
v-model:value="searchTitle"
allow-clear
placeholder="搜索文章标题"
class="search-input"
@pressEnter="applySearch"
>
<template #prefix>
<SearchOutlined style="color: #bfbfbf" />
</template>
</a-input>
<a-button type="primary" @click="applySearch">搜索</a-button>
</div>
</div>
<a-table
row-key="id"
class="modern-table publish-table"
:columns="tableColumns"
:data-source="publishTasks"
:pagination="false"
:loading="loading"
:row-class-name="rowClassName"
>
<template #emptyText>
<a-empty description="暂无匹配的发布任务" />
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ record.title }}</span>
<span class="subtitle">
<template v-if="record.articleId">文章 #{{ record.articleId }} · </template>
{{ isPendingStatus(record.status) ? "待发布队列" : "已发送历史" }}
</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'account'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ record.accountName }}</span>
<span class="subtitle">{{ translatePlatform(record.platform) }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'status'">
<div class="status-cell">
<StatusBadge :tone="statusTone(record.status)" :label="statusLabel(record.status)" />
<span v-if="isPendingStatus(record.status)" class="status-note">优先展示</span>
</div>
</template>
<template v-else-if="column.key === 'meta'">
<div class="cell-primary-secondary meta-cell">
<div class="info-stack">
<span class="subtitle strong-text">{{ record.summary }}</span>
<span v-if="record.errorMessage" class="error-text">{{ record.errorMessage }}</span>
<span class="subtitle">
尝试 {{ record.attempts }}
<template v-if="record.leaseExpiresAt"> · 租约 {{ formatRelativeTime(record.leaseExpiresAt) }} 到期</template>
<template v-else-if="record.externalArticleUrl"> · 已生成外链</template>
<template v-else-if="record.externalManageUrl"> · 已生成管理链接</template>
</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'time'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title">{{ formatDateTime(record.updatedAt) }}</span>
<span class="subtitle">更新于 {{ formatRelativeTime(record.updatedAt) }}</span>
<span class="subtitle">创建于 {{ formatDateTime(record.createdAt) }}</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'actions'">
<div class="action-buttons">
<a-button
v-if="!isPendingStatus(record.status)"
size="small"
:loading="actionPendingTaskId === record.id"
@click="retryTask(record.id)"
>
再次发送
</a-button>
<span v-else class="action-placeholder">排队中</span>
</div>
</template>
</template>
</a-table>
<div class="table-footer">
<p class="table-note">
当前页固定展示 10 待发布任务会先占满前面的页码 PublishTasks 展示完后才继续显示已发送历史记录
</p>
<a-pagination
:current="currentPage"
:page-size="PAGE_SIZE"
:total="totalCount"
:show-size-changer="false"
@change="handlePageChange"
/>
</div>
</SurfaceCard>
<p v-if="actionMessage" class="success-text">{{ actionMessage }}</p>
<p v-if="error" class="error-text page-feedback">{{ error }}</p>
<p v-if="actionError" class="error-text page-feedback">{{ actionError }}</p>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 18px;
}
.hero-copy {
padding: 24px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #ffffff;
}
.eyebrow,
.hero-summary,
h2 {
margin: 0;
}
.eyebrow {
color: #8c8c8c;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h2 {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.hero-summary {
margin-top: 12px;
max-width: 860px;
color: #8c8c8c;
line-height: 1.6;
font-size: 13px;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 18px;
margin-bottom: 16px;
}
.toolbar-stats,
.toolbar-search,
.status-cell,
.action-buttons {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-stats {
flex-wrap: wrap;
}
.toolbar-search {
justify-content: flex-end;
}
.search-input {
width: 280px;
}
.stat-chip {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border-radius: 999px;
background: #f5f7fa;
color: #4b5563;
font-size: 12px;
font-weight: 600;
}
.cell-primary-secondary {
display: flex;
align-items: center;
min-height: 52px;
}
.info-stack {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.title,
.subtitle,
.table-note,
.status-note,
.action-placeholder,
.success-text,
.error-text {
margin: 0;
}
.title {
color: #111827;
font-size: 14px;
font-weight: 600;
line-height: 1.5;
word-break: break-word;
}
.subtitle {
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
.strong-text {
color: #4b5563;
}
.meta-cell {
align-items: flex-start;
}
.status-note,
.action-placeholder {
color: #8c8c8c;
font-size: 12px;
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 16px;
}
.table-note {
color: #8c8c8c;
font-size: 12px;
line-height: 1.6;
}
.success-text {
color: #15803d;
font-size: 13px;
}
.error-text {
color: #b42318;
font-size: 13px;
}
.page-feedback {
margin: 0;
}
:deep(.publish-table .ant-table-thead > tr > th) {
background: #f8fafc;
color: #4b5563;
font-size: 12px;
font-weight: 600;
border-bottom: 1px solid #e6edf5;
}
:deep(.publish-table .ant-table-tbody > tr > td) {
border-bottom: 1px solid #eef2f7;
vertical-align: top;
background: #ffffff;
}
:deep(.publish-table .publish-row--pending > td) {
background: #fffbeb;
}
:deep(.publish-table .ant-table-tbody > tr:hover > td) {
background: #f8fafc;
}
@media (max-width: 980px) {
.table-toolbar,
.table-footer {
flex-direction: column;
align-items: stretch;
}
.toolbar-search {
justify-content: stretch;
}
.search-input {
width: 100%;
}
}
</style>