Files
geo/apps/desktop-client/src/renderer/views/PublishManagementView.vue
T
root 7ddd3d0c6c feat(publish): handle qiehao real-name auth via shared publisher errors
- Detect qiehao's real-name auth blocking response (code/message) in the
  desktop adapter, surface a Chinese-friendly summary, and propagate the
  detail through the failure result.
- Extract a shared publisher-errors module so the renderer can normalize
  these messages for the publish task table without duplicating regex.
- Pre-process article HTML for qiehao before upload via a new
  packages/publisher-platforms/src/qiehao.ts entry point.
- Update the manual self-test notes for jianshu and qiehao with the
  latest results.

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

850 lines
24 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,
ExportOutlined,
ReloadOutlined,
SearchOutlined,
SendOutlined,
} 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 { useDesktopRuntime } from "../composables/useDesktopRuntime";
import { formatDateTime, formatRelativeTime, titleCaseToken } from "../lib/formatters";
import { desktopPublishMediaCatalog } from "../lib/media-catalog";
import { normalizePublisherErrorMessage } from "../../shared/publisher-errors";
const PAGE_SIZE = 10;
const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[number]> = Object.fromEntries(
desktopPublishMediaCatalog.map((item) => [item.id, item]),
);
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 platformMeta(platform: string) {
const key = (platform ?? "").toLowerCase();
return publishPlatformIndex[key] ?? null;
}
function translatePlatform(platform: string) {
return platformMeta(platform)?.label || titleCaseToken(platform);
}
function platformLogo(platform: string): string | null {
return platformMeta(platform)?.logoUrl ?? null;
}
function platformAccent(platform: string): string {
return platformMeta(platform)?.accent ?? "#64748b";
}
function platformShortName(platform: string): string {
return platformMeta(platform)?.shortName ?? titleCaseToken(platform).slice(0, 1);
}
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 normalizePublisherErrorMessage(normalized) ?? 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;
}
}
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 : "打开外链失败";
}
}
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-container">
<section class="hero-card">
<div class="hero-header">
<div class="hero-title">
<p class="eyebrow">PUBLISH EXECUTION DESK</p>
<h2>发布管理</h2>
<p class="summary">
统一用一个 table 查看当前设备负责的待发布队列与已发送历史页面固定每页 10 待发布任务会始终排在最前面只有当前页的 PublishTasks 展示完后才会继续补已发送历史记录
</p>
</div>
<div class="hero-actions">
<a-button type="primary" ghost class="modern-btn" :loading="loading" @click="refreshTasks()">
<template #icon><ReloadOutlined /></template>
刷新状态
</a-button>
</div>
</div>
<div class="stats-strip">
<div class="stat-card">
<div class="stat-label">历史总数</div>
<div class="stat-value">{{ totalCount }}</div>
</div>
<div class="stat-card">
<div class="stat-label">等待发布</div>
<div class="stat-value">{{ pendingCount }}</div>
</div>
<div class="stat-card">
<div class="stat-label">已发送</div>
<div class="stat-value">{{ historyTotal }}</div>
</div>
</div>
</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">
<div class="hero-card" style="padding-bottom: 0;">
<div class="hero-header" style="padding: 24px 32px 16px; border-bottom: 1px solid #eef2f6; align-items: center;">
<div style="flex: 1;">
<h3 class="section-title">待发布与已发送记录</h3>
<p class="section-desc" style="margin-top: 4px;">支持按文章标题搜索分页与总数以服务端返回的 PublishTasks 结果为准</p>
</div>
<!-- Filters ToolBar -->
<div style="display: flex; gap: 12px; align-items: center;">
<a-input
v-model:value="searchTitle"
allow-clear
placeholder="搜索文章标题"
style="width: 280px;"
@pressEnter="applySearch"
>
<template #prefix>
<SearchOutlined style="color: #94a3b8" />
</template>
</a-input>
<a-button type="primary" class="modern-btn" style="border-radius: 8px; font-weight: 500;" @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>
<div style="padding: 60px; color: #94a3b8;"><a-empty description="暂无匹配的发布任务" /></div>
</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 platform-line">
<span class="platform-logo-mini">
<img
v-if="platformLogo(record.platform)"
:src="platformLogo(record.platform) || ''"
:alt="translatePlatform(record.platform)"
/>
<span
v-else
class="platform-logo-fallback"
:style="{ color: platformAccent(record.platform) }"
>
{{ platformShortName(record.platform) }}
</span>
</span>
<span>{{ translatePlatform(record.platform) }}</span>
</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'status'">
<div class="status-cell">
<StatusBadge :tone="statusTone(record.status)" :label="statusLabel(record.status)" />
</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 mono-detail">
尝试 {{ record.attempts }}
<template v-if="record.leaseExpiresAt"> · 租约 {{ formatRelativeTime(record.leaseExpiresAt) }} 到期</template>
<template v-else-if="record.externalArticleUrl">
·
<a-tooltip :title="record.externalArticleUrl" overlay-class-name="external-url-tooltip">
<a
class="external-task-link"
:href="record.externalArticleUrl"
target="_blank"
rel="noopener noreferrer"
@click.prevent.stop="openExternalUrl(record.externalArticleUrl)"
>
<ExportOutlined />
打开外链
</a>
</a-tooltip>
</template>
<template v-else-if="record.externalManageUrl">
·
<a-tooltip :title="record.externalManageUrl" overlay-class-name="external-url-tooltip">
<a
class="external-task-link"
:href="record.externalManageUrl"
target="_blank"
rel="noopener noreferrer"
@click.prevent.stop="openExternalUrl(record.externalManageUrl)"
>
<ExportOutlined />
打开管理页
</a>
</a-tooltip>
</template>
</span>
</div>
</div>
</template>
<template v-else-if="column.key === 'time'">
<div class="cell-primary-secondary">
<div class="info-stack">
<span class="title mono-text">{{ 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-popconfirm
v-if="!isPendingStatus(record.status)"
title="确定要重试本次发布吗?"
placement="topRight"
ok-text="确定"
cancel-text="取消"
@confirm="retryTask(record.id)"
>
<a-tooltip title="再次发送" placement="top">
<a-button
type="text"
class="icon-action-btn"
:loading="actionPendingTaskId === record.id"
>
<template #icon><SendOutlined /></template>
</a-button>
</a-tooltip>
</a-popconfirm>
<a-tooltip v-else title="排队中...">
<div class="action-placeholder-icon">
<ClockCircleOutlined />
</div>
</a-tooltip>
</div>
</template>
</template>
</a-table>
<div class="table-footer">
<a-pagination
:current="currentPage"
:page-size="PAGE_SIZE"
:total="totalCount"
:show-size-changer="false"
@change="handlePageChange"
/>
</div>
</div>
</section>
</section>
</template>
<style scoped>
.page-container {
display: flex;
flex-direction: column;
gap: 32px;
padding-bottom: 40px;
max-width: 1400px;
}
/* Hero Section */
.hero-card {
background: #ffffff;
border-radius: 20px;
border: 1px solid #eef2f6;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px -2px rgba(0, 0, 0, 0.01);
overflow: hidden;
}
.hero-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 36px 40px;
}
.eyebrow {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.hero-title h2 {
margin: 0;
font-size: 32px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.02em;
}
.summary {
margin: 16px 0 0;
max-width: 800px;
color: #475569;
line-height: 1.6;
font-size: 15px;
}
.modern-btn {
border-radius: 8px;
height: 40px;
padding: 0 20px;
font-weight: 600;
}
/* Stats Strip */
.stats-strip {
display: flex;
padding: 24px 40px;
background: #f8fafc;
border-top: 1px solid #eef2f6;
gap: 40px;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
}
.stat-label {
font-size: 14px;
font-weight: 500;
color: #64748b;
}
.stat-value {
font-size: 40px;
font-weight: 700;
color: #0f172a;
line-height: 1;
font-feature-settings: "tnum";
}
/* Content Section */
.content-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.section-title {
margin: 0;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.section-desc {
margin: 8px 0 0;
color: #64748b;
font-size: 15px;
line-height: 1.5;
}
/* Modern Enterprise Table Styles */
:deep(.modern-table) {
padding: 0 32px;
}
:deep(.modern-table .ant-table-thead > tr > th) {
background-color: transparent !important;
color: #64748b;
font-weight: 600;
font-size: 13px;
border-bottom: 1px solid #e2e8f0;
padding: 16px 12px;
}
:deep(.modern-table .ant-table-tbody > tr > td) {
padding: 16px 12px;
border-bottom: 1px solid #f1f5f9;
color: #0f172a;
vertical-align: top;
transition: background-color 0.2s ease;
}
:deep(.modern-table .ant-table-tbody > tr:last-child > td) {
border-bottom: none;
}
:deep(.modern-table .ant-table-tbody > tr:hover > td) {
background-color: #f8fafc !important;
}
:deep(.publish-table .publish-row--pending > td) {
background: #fdfcee !important;
}
.cell-primary-secondary {
display: flex;
flex-direction: column;
}
.info-stack {
display: flex;
flex-direction: column;
gap: 2px;
}
.info-stack .title {
font-weight: 600;
color: #0f172a;
font-size: 14px;
line-height: 1.5;
}
.info-stack .subtitle {
color: #64748b;
font-size: 13px;
line-height: 1.5;
}
.info-stack .mono-text {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.info-stack .mono-detail {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: #94a3b8;
}
.platform-line {
display: inline-flex;
align-items: center;
gap: 6px;
}
.platform-logo-mini {
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #ffffff;
border-radius: 4px;
box-shadow: inset 0 0 0 1px #e2e8f0;
overflow: hidden;
flex: 0 0 auto;
}
.platform-logo-mini img {
width: 14px;
height: 14px;
object-fit: contain;
}
.platform-logo-fallback {
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.external-task-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: #2563eb;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
text-decoration: none;
}
.external-task-link:hover {
color: #1d4ed8;
text-decoration: underline;
}
.info-stack .strong-text {
color: #1e293b;
font-weight: 500;
}
.error-text {
color: #ef4444;
font-size: 13px;
margin-top: 4px;
font-weight: 500;
}
.success-text {
color: #10b981;
font-size: 13px;
font-weight: 500;
}
/* Action Buttons */
.action-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.icon-action-btn {
color: #64748b;
border-radius: 8px;
transition: all 0.2s ease;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
}
.icon-action-btn:hover {
background: #f1f5f9;
color: #0ea5e9;
border-color: #e2e8f0;
}
.action-placeholder-icon {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 16px;
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px 32px;
border-top: 1px solid #eef2f6;
background: transparent;
}
.table-note {
color: #94a3b8;
font-size: 13px;
}
.feedback-banner {
padding: 16px 20px;
border-radius: 12px;
background: #fff;
border: 1px solid transparent;
}
.feedback-banner.error {
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;
}
/* Responsiveness */
@media (max-width: 1024px) {
.hero-header {
flex-direction: column;
gap: 24px;
}
.stats-strip {
flex-wrap: wrap;
gap: 24px;
}
}
</style>