Files
geo/apps/desktop-client/src/renderer/views/PublishManagementView.vue
T
root 09295d11a1 feat(monitoring): decouple AI platforms from media_platforms and expand catalog to 6
Add ai_platforms table + shared catalog (yuanbao/kimi/wenxin/deepseek/doubao/qwen),
drop FKs from platform_accounts and desktop_tasks to media_platforms, and wire
target_account_id + platform into DesktopTaskEvent so the desktop runtime no
longer has to infer them from local state. Desktop client gains generic AI page
detection for binding, drops stale-business-date monitor tasks at the edge, and
refactors AccountsView/AiPlatformsView around the shared catalog. Admin tracking
view surfaces per-platform sampling status cards.

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

692 lines
19 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, 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 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-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">
<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: 28px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: linear-gradient(135deg, #ffffff, #f8fafc);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.03), 0 2px 4px -2px rgba(0, 0, 0, 0.03);
}
.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 {
display: flex;
align-items: center;
gap: 10px;
}
.action-buttons {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.icon-action-btn {
color: #64748b;
border-radius: 6px;
transition: all 0.2s ease;
background: transparent;
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
}
.icon-action-btn:hover {
background: #f1f5f9;
color: #0ea5e9;
}
.action-placeholder-icon {
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
color: #94a3b8;
font-size: 16px;
}
.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: #f8fafc;
color: #475569;
font-size: 12px;
font-weight: 600;
border: 1px solid #e2e8f0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.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: #475569;
font-size: 13px;
font-weight: 600;
border-bottom: 1px solid #e2e8f0;
padding: 12px 16px;
}
:deep(.publish-table .ant-table-tbody > tr > td) {
border-bottom: 1px solid #f1f5f9;
vertical-align: top;
background: #ffffff;
padding: 16px;
transition: background-color 0.2s ease;
}
:deep(.publish-table .publish-row--pending > td) {
background: #fefce8;
}
: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>