feat(schedule-tasks): support auto-publish via media accounts

Replace the legacy target_platform string on schedule tasks with a
workspace-aware auto-publish payload (auto_publish + publish_account_ids
JSONB + cover_asset_url + cover_image_asset_id) and migrate the schema,
sqlc queries, generated models, domain struct, ScheduleTaskService DTOs,
and dispatch worker to round-trip the new fields. PromptRuleGeneration
gains a WithPublishJobService hook so executeGeneration can enqueue an
auto-publish job once the article is ready, and worker-generate wires
the publish-job service in. On the admin-web side, extract
PublishArticleModal's account-card builders into a shared
publish-account-cards module, rebuild GenerateTaskDrawer's schedule
mode around account selection plus a CoverPickerModal, and surface the
new "auto publish" column on ScheduleTaskTab. Shared types and i18n
strings cover the new fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 01:32:19 +08:00
parent c9267d2fae
commit 6e6e19cccb
22 changed files with 1380 additions and 603 deletions
@@ -2,61 +2,29 @@
import { MinusCircleFilled } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, notification } from "ant-design-vue";
import type { ArticleDetail, DesktopAccountInfo, MediaPlatform } from "@geo/shared-types";
import type { ArticleDetail } from "@geo/shared-types";
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
import { articlesApi, mediaApi, publishJobsApi, resolveApiURL, tenantAccountsApi } from "@/lib/api";
import { coverUploadRequired, deriveCoverFileName } from "@/lib/cover-requirements";
import { resolveAccountCheckedAt, resolveAccountHealth } from "@/lib/desktop-account-runtime";
import { formatError } from "@/lib/errors";
import {
getPublishPlatformMeta,
isPublishPlatformId,
normalizePublishPlatformId,
accountInitial,
buildPublishAccountCards,
buildPublishPlatformMap,
clientStatusColor,
clientStatusLabel,
healthColor,
healthLabel,
publishStateLabel,
type PublishAccountCard,
} from "@/lib/publish-account-cards";
import {
normalizePublishPlatformIds,
} from "@/lib/publish-platforms";
type PublishState = "immediate" | "queued" | "unavailable";
const platformLogoCatalog: Record<string, string> = {
toutiaohao: "/logos/logo_toutiao.png",
baijiahao: "/logos/logo_baijiahao.png",
sohuhao: "/logos/logo_souhu.png",
qiehao: "/logos/logo_qiehao.png",
zhihu: "/logos/logo_zhihu.png",
wangyihao: "/logos/logo_wangyihao.png",
jianshu: "/logos/logo_jianshu.svg",
bilibili: "/logos/logo_bilibili.png",
juejin: "/logos/logo_juejin.png",
smzdm: "/logos/logo_smzdm.svg",
weixin_gzh: "/logos/logo_weixin_gzh.svg",
zol: "/logos/logo_zol.png",
dongchedi: "/logos/logo_dongchedi.svg",
};
interface PublishAccountCard {
id: string;
platformId: string;
platformName: string;
platformShortName: string;
platformAccent: string;
platformLogoUrl: string | null;
displayName: string;
platformUid: string;
avatarUrl: string | null;
health: DesktopAccountInfo["health"];
verifiedAt: string | null;
clientId: string | null;
clientOnline: boolean | null;
clientDeviceName: string | null;
clientLastSeenAt: string | null;
publishState: PublishState;
selectable: boolean;
statusText: string;
}
const props = defineProps<{
open: boolean;
articleId: number | null;
@@ -123,77 +91,13 @@ watch(
{ immediate: true },
);
const platformMap = computed<Map<string, MediaPlatform>>(() => {
return new Map(
(platformsQuery.data.value ?? []).map((platform: MediaPlatform) => [
normalizePublishPlatformId(platform.platform_id),
platform,
]),
);
});
const publishAccounts = computed(() => {
return (accountsQuery.data.value ?? []).filter((account) => {
if (account.deleted_at) {
return false;
}
const platformId = normalizePublishPlatformId(account.platform);
return platformMap.value.has(platformId) || isPublishPlatformId(platformId);
});
});
const platformMap = computed(() => buildPublishPlatformMap(platformsQuery.data.value ?? []));
const articlePlatformIds = computed(() => normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? []));
const accountCards = computed<PublishAccountCard[]>(() => {
const preferredPlatforms = new Set(articlePlatformIds.value);
return [...publishAccounts.value]
.map((account) => {
const platformId = normalizePublishPlatformId(account.platform);
const platform = platformMap.value.get(platformId);
const fallback = getPublishPlatformMeta(platformId);
const publishState = resolvePublishState(account);
const health = resolveAccountHealth(account);
return {
id: account.id,
platformId,
platformName: platform?.name || fallback.name,
platformShortName: platform?.short_name || fallback.shortName,
platformAccent: platform?.accent_color || fallback.accent,
platformLogoUrl: resolvePlatformLogoUrl(platformId, platform?.logo_url),
displayName: account.display_name,
platformUid: normalizePlatformUid(account.platform_uid),
avatarUrl: resolveApiURL(account.avatar_url),
health,
verifiedAt: resolveAccountCheckedAt(account),
clientId: account.client_id,
clientOnline: account.client_online,
clientDeviceName: account.client_device_name,
clientLastSeenAt: account.client_last_seen_at,
publishState,
selectable: publishState !== "unavailable",
statusText: resolveStatusText(account, publishState),
};
})
.sort((left, right) => {
const preferredGap =
Number(preferredPlatforms.has(right.platformId)) - Number(preferredPlatforms.has(left.platformId));
if (preferredGap !== 0) {
return preferredGap;
}
const publishGap = publishRank(left.publishState) - publishRank(right.publishState);
if (publishGap !== 0) {
return publishGap;
}
const leftVerifiedAt = Date.parse(left.verifiedAt ?? "") || 0;
const rightVerifiedAt = Date.parse(right.verifiedAt ?? "") || 0;
return rightVerifiedAt - leftVerifiedAt;
});
});
const accountCards = computed<PublishAccountCard[]>(() =>
buildPublishAccountCards(accountsQuery.data.value ?? [], platformMap.value, articlePlatformIds.value),
);
watch(
accountCards,
@@ -391,112 +295,6 @@ function handleRemoveCover(): void {
coverImageAssetId.value = null;
}
function resolvePublishState(account: DesktopAccountInfo): PublishState {
if (resolveAccountHealth(account) !== "live") {
return "unavailable";
}
if (!account.client_id) {
return "unavailable";
}
return account.client_online === true ? "immediate" : "queued";
}
function resolveStatusText(account: DesktopAccountInfo, publishState: PublishState): string {
if (publishState === "immediate") {
return "客户端在线,RabbitMQ 会立即唤醒桌面端开始发布。";
}
if (publishState === "queued") {
return "客户端当前离线,任务会先落库排队,等桌面端上线后自动继续发布。";
}
if (resolveAccountHealth(account) !== "live") {
return "该账号授权状态异常,请先在桌面端重新校验或重新授权。";
}
return "该账号还没有绑定桌面客户端,当前不能进入发布队列。";
}
function publishRank(state: PublishState): number {
switch (state) {
case "immediate":
return 0;
case "queued":
return 1;
default:
return 2;
}
}
function publishStateLabel(state: PublishState): string {
switch (state) {
case "immediate":
return "立即发布";
case "queued":
return "离线排队";
default:
return "不可发布";
}
}
function healthLabel(health: DesktopAccountInfo["health"]): string {
switch (health) {
case "live":
return "授权正常";
case "captcha":
return "待处理";
case "risk":
return "风险观察";
default:
return "授权异常";
}
}
function healthColor(health: DesktopAccountInfo["health"]): string {
switch (health) {
case "live":
return "green";
case "captcha":
return "orange";
case "risk":
return "gold";
default:
return "red";
}
}
function clientStatusLabel(card: PublishAccountCard): string {
if (!card.clientId) {
return "未绑定客户端";
}
return card.clientOnline ? "客户端在线" : "客户端离线";
}
function clientStatusColor(card: PublishAccountCard): string {
if (!card.clientId) {
return "default";
}
return card.clientOnline ? "green" : "gold";
}
function accountInitial(card: PublishAccountCard): string {
const trimmed = card.displayName.trim();
return trimmed ? trimmed.slice(0, 1).toUpperCase() : card.platformShortName;
}
function resolvePlatformLogoUrl(platformId: string, remoteLogoUrl?: string | null): string | null {
const localLogoUrl = platformLogoCatalog[platformId];
if (localLogoUrl) {
return localLogoUrl;
}
const resolvedRemoteLogoUrl = resolveApiURL(remoteLogoUrl);
return resolvedRemoteLogoUrl || null;
}
function normalizePlatformUid(value?: string | null): string {
let normalized = String(value ?? "").trim();
while (normalized.toLowerCase().startsWith("platform:")) {
normalized = normalized.slice("platform:".length).trim();
}
return normalized || "--";
}
</script>
<template>