From 08ace0a9e057109e71ed0dbdc08cdc54f00fce6f Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 6 Apr 2026 15:41:49 +0800 Subject: [PATCH] feat: enhance platform management and synchronization in ArticleEditor and PublishArticle components --- .../src/components/GenerateTaskDrawer.vue | 95 +++++++++++++------ .../src/components/PublishArticleModal.vue | 59 ++++++++++-- .../components/PublishPlatformSelector.vue | 5 +- apps/admin-web/src/i18n/messages/en-US.ts | 11 ++- apps/admin-web/src/i18n/messages/zh-CN.ts | 10 +- apps/admin-web/src/lib/publish-platforms.ts | 72 ++++++++------ .../admin-web/src/views/ArticleEditorView.vue | 45 +++++++-- 7 files changed, 209 insertions(+), 88 deletions(-) diff --git a/apps/admin-web/src/components/GenerateTaskDrawer.vue b/apps/admin-web/src/components/GenerateTaskDrawer.vue index f9249b8..4ffe20d 100644 --- a/apps/admin-web/src/components/GenerateTaskDrawer.vue +++ b/apps/admin-web/src/components/GenerateTaskDrawer.vue @@ -8,10 +8,11 @@ import { useI18n } from "vue-i18n"; import PromptRuleModal from "@/components/PromptRuleModal.vue"; import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue"; -import { generateApi, promptRulesApi, schedulesApi } from "@/lib/api"; +import { generateApi, mediaApi, promptRulesApi, schedulesApi } from "@/lib/api"; import { formatError } from "@/lib/errors"; import { - getAllPublishPlatforms, + buildPublishPlatformOptions, + normalizePublishPlatformIds, parseTargetPlatforms, serializeTargetPlatforms, } from "@/lib/publish-platforms"; @@ -44,7 +45,26 @@ const form = reactive({ }); const isSchedule = computed(() => props.mode === "schedule"); -const allPlatforms = computed(() => getAllPublishPlatforms()); + +const platformsQuery = useQuery({ + queryKey: ["media", "platforms", "task-drawer"], + enabled: computed(() => props.open), + queryFn: () => mediaApi.platforms(), +}); + +const accountsQuery = useQuery({ + queryKey: ["media", "platform-accounts", "task-drawer"], + enabled: computed(() => props.open), + queryFn: () => mediaApi.accounts(), +}); + +const allPlatforms = computed(() => + buildPublishPlatformOptions(platformsQuery.data.value ?? [], accountsQuery.data.value), +); + +const platformsLoading = computed( + () => platformsQuery.isPending.value || accountsQuery.isPending.value, +); const rulesQuery = useQuery({ queryKey: ["promptRules", "simple"], @@ -89,12 +109,7 @@ onBeforeUnmount(() => { const createScheduleMutation = useMutation({ mutationFn: () => - schedulesApi.create({ - name: form.name.trim(), - prompt_rule_id: form.promptRuleId!, - cron_expr: buildDailyCron(form.scheduleTime), - target_platform: serializeTargetPlatforms(form.platformIds), - }), + schedulesApi.create(buildSchedulePayload()), onSuccess: async () => { message.success(t("custom.messages.scheduleCreated")); emit("update:open", false); @@ -105,12 +120,7 @@ const createScheduleMutation = useMutation({ const updateScheduleMutation = useMutation({ mutationFn: () => - schedulesApi.update(props.task!.id, { - name: form.name.trim(), - prompt_rule_id: form.promptRuleId!, - cron_expr: buildDailyCron(form.scheduleTime), - target_platform: serializeTargetPlatforms(form.platformIds), - }), + schedulesApi.update(props.task!.id, buildSchedulePayload()), onSuccess: async () => { message.success(t("custom.messages.scheduleUpdated")); emit("update:open", false); @@ -121,18 +131,7 @@ const updateScheduleMutation = useMutation({ const instantGenerateMutation = useMutation({ mutationFn: () => - generateApi.fromRule({ - prompt_rule_id: form.promptRuleId!, - target_platform: serializeTargetPlatforms(form.platformIds), - extra_params: { - task_name: form.name.trim(), - generation_mode: "instant", - enable_web_search: form.enableWebSearch, - generate_count: form.generateCount, - target_platforms: form.platformIds, - cover_file_name: coverFileName.value || null, - }, - }), + generateApi.fromRule(buildInstantPayload()), onSuccess: async () => { message.success(t("custom.messages.generateSubmitted")); emit("update:open", false); @@ -162,6 +161,36 @@ function hydrateForm(): void { resetCoverState(); } +function getSelectedPlatformIds(): string[] { + return normalizePublishPlatformIds(form.platformIds); +} + +function buildSchedulePayload() { + return { + name: form.name.trim(), + prompt_rule_id: form.promptRuleId!, + cron_expr: buildDailyCron(form.scheduleTime), + target_platform: serializeTargetPlatforms(getSelectedPlatformIds()), + }; +} + +function buildInstantPayload() { + const platformIds = getSelectedPlatformIds(); + + return { + prompt_rule_id: form.promptRuleId!, + target_platform: serializeTargetPlatforms(platformIds), + extra_params: { + task_name: form.name.trim(), + generation_mode: "instant", + enable_web_search: form.enableWebSearch, + generate_count: form.generateCount, + target_platforms: platformIds, + cover_file_name: coverFileName.value || null, + }, + }; +} + function resetCoverState(): void { if (coverPreviewUrl.value) { URL.revokeObjectURL(coverPreviewUrl.value); @@ -323,7 +352,17 @@ function padTime(value: string): string {

{{ t("custom.task.platformsHint") }}

- + +
diff --git a/apps/admin-web/src/components/PublishArticleModal.vue b/apps/admin-web/src/components/PublishArticleModal.vue index 485838f..3862216 100644 --- a/apps/admin-web/src/components/PublishArticleModal.vue +++ b/apps/admin-web/src/components/PublishArticleModal.vue @@ -9,11 +9,15 @@ import type { PublisherPublishResponse, PublisherPublishTaskResult, } from "@geo/shared-types"; -import { computed, h, ref, watch } from "vue"; +import { computed, h, ref, watch, watchEffect } from "vue"; import { useI18n } from "vue-i18n"; import { articlesApi, getApiBaseURL, mediaApi } from "@/lib/api"; import { formatError } from "@/lib/errors"; +import { + normalizePublishPlatformId, + normalizePublishPlatformIds, +} from "@/lib/publish-platforms"; import { publishWithPublisherPlugin } from "@/lib/publisher-plugin"; import { loadPublisherRuntimeState } from "@/lib/publisher-runtime"; @@ -39,6 +43,7 @@ const pluginVersion = ref(); const pluginInstallationId = ref(null); const localPlatforms = ref([]); const fileList = ref([]); +const selectionHydrated = ref(false); function handleBeforeUpload(file: File) { const url = URL.createObjectURL(file); @@ -84,27 +89,63 @@ watch( fileList.value = []; pluginInstallationId.value = null; localPlatforms.value = []; + selectionHydrated.value = false; return; } + selectionHydrated.value = false; await refreshRuntime(); }, { immediate: true }, ); +watchEffect(() => { + if (!props.open || selectionHydrated.value) { + return; + } + + if ( + detailQuery.isPending.value || + accountsQuery.isPending.value || + platformsQuery.isPending.value || + runtimeLoading.value + ) { + return; + } + + const selectedPlatforms = new Set(normalizePublishPlatformIds(detailQuery.data.value?.platforms ?? [])); + selectedAccountIds.value = accountCards.value + .filter((account) => account.accountId && account.selectable && selectedPlatforms.has(account.platformId)) + .map((account) => account.accountId as number); + selectionHydrated.value = true; +}); + const localPlatformMap = computed(() => { - return new Map(localPlatforms.value.map((item) => [item.platform_id, item])); + return new Map(localPlatforms.value.map((item) => [normalizePublishPlatformId(item.platform_id), item])); +}); + +const accountGroups = computed(() => { + const groups = new Map(); + for (const account of accountsQuery.data.value ?? []) { + const platformId = normalizePublishPlatformId(account.platform_id); + const list = groups.get(platformId) ?? []; + list.push(account); + groups.set(platformId, list); + } + return groups; }); const platformNameMap = computed(() => { - return new Map((platformsQuery.data.value ?? []).map((platform) => [platform.platform_id, platform.name])); + return new Map( + (platformsQuery.data.value ?? []).map((platform) => [normalizePublishPlatformId(platform.platform_id), platform.name]), + ); }); const accountCards = computed(() => { - const accountMap = new Map((accountsQuery.data.value ?? []).map((account) => [account.platform_id, account])); - return (platformsQuery.data.value ?? []).map((platform) => { - const account = accountMap.get(platform.platform_id) ?? null; - const local = localPlatformMap.value.get(platform.platform_id); + const platformId = normalizePublishPlatformId(platform.platform_id); + const accounts = accountGroups.value.get(platformId) ?? []; + const account = accounts[0] ?? null; + const local = localPlatformMap.value.get(platformId); const localConnected = Boolean(local?.connected); const uidMatches = account ? localConnected && (!local?.platform_uid || local.platform_uid === account.platform_uid) @@ -112,9 +153,9 @@ const accountCards = computed(() => { const selectable = Boolean(account && account.status === "active" && uidMatches && pluginInstalled.value); return { - key: account?.id ?? platform.platform_id, + key: account?.id ?? platformId, accountId: account?.id ?? null, - platformId: platform.platform_id, + platformId, platformName: platform.name, platformShortName: platform.short_name, platformAccentColor: platform.accent_color, diff --git a/apps/admin-web/src/components/PublishPlatformSelector.vue b/apps/admin-web/src/components/PublishPlatformSelector.vue index 90d81be..ef936f4 100644 --- a/apps/admin-web/src/components/PublishPlatformSelector.vue +++ b/apps/admin-web/src/components/PublishPlatformSelector.vue @@ -3,7 +3,6 @@ import { computed } from "vue"; import { useI18n } from "vue-i18n"; import type { PublishPlatformOption } from "@/lib/publish-platforms"; -import { isPlatformBound } from "@/lib/publish-platforms"; const props = withDefaults(defineProps<{ modelValue: string[]; @@ -75,9 +74,9 @@ function togglePlatform(platformId: string): void { - {{ isPlatformBound(platform.id) ? (platform.accountLabel ?? t("custom.task.platformConnected")) : t("custom.task.platformUnauthorized") }} + {{ platform.bound ? (platform.accountLabel ?? t("custom.task.platformConnected")) : t("custom.task.platformUnauthorized") }}
diff --git a/apps/admin-web/src/i18n/messages/en-US.ts b/apps/admin-web/src/i18n/messages/en-US.ts index 503e58c..46cb133 100644 --- a/apps/admin-web/src/i18n/messages/en-US.ts +++ b/apps/admin-web/src/i18n/messages/en-US.ts @@ -401,7 +401,7 @@ const enUS = { saveAndLeave: "Save and leave", }, platformsTitle: "Publish platforms", - platformsHint: "This uses the shared publish-platform selector. For now it shows placeholder bound platforms on the frontend.", + platformsHint: "This stays in sync with Media Management so you can edit the article's publish platforms directly.", coverTitle: "Cover image", coverHint: "Upload a cover asset before publishing. This is local preview only for now.", coverUpload: "Upload cover image", @@ -516,7 +516,7 @@ const enUS = { subtitle: "Multi-platform Dispatch", description: "Choose tenant-bound accounts that are currently signed in locally. The extension executes the real publish flow and writes back the resulting links.", platformsTitle: "Target platforms", - platformsHint: "Only tenant-bound accounts are listed here. Accounts with missing or mismatched local sessions are disabled automatically.", + platformsHint: "Previously selected and bound accounts are prefilled first. Unbound or locally unavailable platforms stay disabled.", coverTitle: "Cover image", coverHint: "This version uses a cover asset URL. Upload and crop can be wired later.", coverPlaceholder: "Enter an optional cover asset URL", @@ -646,10 +646,11 @@ const enUS = { promptPlaceholder: "Select a prompt", createPrompt: "New Prompt", platforms: "Publish platforms", - platformsHint: "Choose one or more publish platforms. Only bound platforms are shown here.", + platformsHint: "Synced from Media Management. This field is optional and supports multiple selection.", platformConnected: "Connected", - platformEmptyTitle: "Publish platforms are coming", - platformEmptyHint: "Once the media integration is wired, bound tenant platforms will appear here automatically.", + platformUnauthorized: "Unauthorized", + platformEmptyTitle: "No publish platforms", + platformEmptyHint: "No platform data is available yet. Please verify the Media Management setup first.", cover: "Cover image", coverHint: "Keep the cover clear, complete, and visually polished.", coverUpload: "Upload cover image", diff --git a/apps/admin-web/src/i18n/messages/zh-CN.ts b/apps/admin-web/src/i18n/messages/zh-CN.ts index 55cc14f..f1b586a 100644 --- a/apps/admin-web/src/i18n/messages/zh-CN.ts +++ b/apps/admin-web/src/i18n/messages/zh-CN.ts @@ -408,7 +408,7 @@ const zhCN = { saveAndLeave: "保存并返回", }, platformsTitle: "发布平台", - platformsHint: "这里会复用统一的发布平台组件,当前先展示前端占位的已绑定平台。", + platformsHint: "这里会同步媒体管理中的平台和绑定状态,支持直接编辑文章的发布平台。", coverTitle: "封面图", coverHint: "发布前可先上传封面素材,当前只做本地预览。", coverUpload: "上传封面图", @@ -523,7 +523,7 @@ const zhCN = { subtitle: "Multi-platform Dispatch", description: "选择当前浏览器已登录且租户已绑定的平台,插件会逐个平台执行真实发布并回写链接。", platformsTitle: "发布平台", - platformsHint: "仅展示当前租户已绑定的账号。插件本地登录态异常的平台会自动禁用。", + platformsHint: "会优先回填文章里已选择且已绑定的账号,未绑定或本地登录态异常的平台不可选。", coverTitle: "封面图", coverHint: "当前版本先使用封面素材 URL,后续可以接入上传与裁剪。", coverPlaceholder: "请输入封面素材 URL(可选)", @@ -654,11 +654,11 @@ const zhCN = { promptPlaceholder: "请选择Prompt", createPrompt: "新建Prompt", platforms: "发布平台", - platformsHint: "请选择需要发布的平台,支持多选", + platformsHint: "同步媒体管理中的平台和绑定状态,可不选,支持多选", platformConnected: "已绑定", platformUnauthorized: "未授权", - platformEmptyTitle: "发布平台接入中", - platformEmptyHint: "媒体平台接口接入后,这里会自动展示当前租户已绑定的平台。", + platformEmptyTitle: "暂无发布平台", + platformEmptyHint: "当前还没有可用的平台数据,请先检查媒体管理配置。", cover: "封面图", coverHint: "请保证封面清晰、美观和完整", coverUpload: "上传封面图", diff --git a/apps/admin-web/src/lib/publish-platforms.ts b/apps/admin-web/src/lib/publish-platforms.ts index bda857d..e0e2ff5 100644 --- a/apps/admin-web/src/lib/publish-platforms.ts +++ b/apps/admin-web/src/lib/publish-platforms.ts @@ -1,44 +1,66 @@ +import type { MediaPlatform, PlatformAccount } from "@geo/shared-types"; + export interface PublishPlatformOption { id: string; name: string; shortName: string; accent: string; accountLabel?: string; + bound?: boolean; } -const platformCatalog: Record> = { - zhihu: { id: "zhihu", name: "知乎", shortName: "知", accent: "#1677ff" }, +const platformCatalog: Record> = { + toutiaohao: { id: "toutiaohao", name: "头条号", shortName: "头", accent: "#f5222d" }, baijiahao: { id: "baijiahao", name: "百家号", shortName: "百", accent: "#31445a" }, - toutiao: { id: "toutiao", name: "头条号", shortName: "头", accent: "#f5222d" }, - souhu: { id: "souhu", name: "搜狐号", shortName: "搜", accent: "#fa8c16" }, - qq: { id: "qq", name: "企鹅号", shortName: "Q", accent: "#111827" }, - wechat: { id: "wechat", name: "公众号", shortName: "微", accent: "#13c26b" }, - bilibili: { id: "bilibili", name: "bilibili", shortName: "B", accent: "#eb2f96" }, + sohuhao: { id: "sohuhao", name: "搜狐号", shortName: "搜", accent: "#fa8c16" }, + qiehao: { id: "qiehao", name: "企鹅号", shortName: "Q", accent: "#111827" }, + zhihu: { id: "zhihu", name: "知乎", shortName: "知", accent: "#1677ff" }, + wangyihao: { id: "wangyihao", name: "网易号", shortName: "网", accent: "#ff4d4f" }, jianshu: { id: "jianshu", name: "简书", shortName: "简", accent: "#f56a5e" }, + bilibili: { id: "bilibili", name: "bilibili", shortName: "B", accent: "#eb2f96" }, juejin: { id: "juejin", name: "稀土掘金", shortName: "掘", accent: "#1677ff" }, smzdm: { id: "smzdm", name: "什么值得买", shortName: "值", accent: "#f5222d" }, + weixin_gzh: { id: "weixin_gzh", name: "微信公众号", shortName: "微", accent: "#13c26b" }, zol: { id: "zol", name: "中关村在线", shortName: "Z", accent: "#ff4d4f" }, dongchedi: { id: "dongchedi", name: "懂车帝", shortName: "懂", accent: "#fadb14" }, }; -const mockBoundPlatformIds = ["zhihu", "wechat", "juejin", "bilibili", "jianshu", "baijiahao"]; - -export function getBoundPublishPlatforms(): PublishPlatformOption[] { - return mockBoundPlatformIds - .map((id) => platformCatalog[id]) - .filter((item): item is Omit => Boolean(item)); +export function normalizePublishPlatformId(platformId?: string | null): string { + return String(platformId ?? "").trim(); } -export function getAllPublishPlatforms(): PublishPlatformOption[] { - return Object.values(platformCatalog); +export function normalizePublishPlatformIds(platformIds?: Array): string[] { + return Array.from( + new Set( + (platformIds ?? []) + .map((item) => normalizePublishPlatformId(item)) + .filter(Boolean), + ), + ); } -export function isPlatformBound(platformId: string): boolean { - return mockBoundPlatformIds.includes(platformId); +export function buildPublishPlatformOptions( + platforms: MediaPlatform[], + accounts?: PlatformAccount[] | null, +): PublishPlatformOption[] { + const boundPlatformIds = new Set( + (accounts ?? []).map((account) => normalizePublishPlatformId(account.platform_id)).filter(Boolean), + ); + + return platforms.map((platform) => { + const id = normalizePublishPlatformId(platform.platform_id); + return { + id, + name: platform.name, + shortName: platform.short_name, + accent: platform.accent_color, + bound: boundPlatformIds.has(id), + }; + }); } export function serializeTargetPlatforms(platformIds: string[]): string | undefined { - const next = Array.from(new Set(platformIds.map((item) => item.trim()).filter(Boolean))); + const next = normalizePublishPlatformIds(platformIds); return next.length ? next.join(",") : undefined; } @@ -47,18 +69,12 @@ export function parseTargetPlatforms(value?: string | null): string[] { return []; } - return Array.from( - new Set( - value - .split(",") - .map((item) => item.trim()) - .filter(Boolean), - ), - ); + return normalizePublishPlatformIds(value.split(",")); } export function getPublishPlatformLabel(platformId: string): string { - return platformCatalog[platformId]?.name ?? platformId; + const normalized = normalizePublishPlatformId(platformId); + return platformCatalog[normalized]?.name ?? normalized; } export function formatPublishPlatformSummary(value?: string | null): string { @@ -67,6 +83,6 @@ export function formatPublishPlatformSummary(value?: string | null): string { } export function formatPublishPlatformList(platformIds?: string[] | null): string { - const labels = (platformIds ?? []).map(getPublishPlatformLabel).filter(Boolean); + const labels = normalizePublishPlatformIds(platformIds ?? []).map(getPublishPlatformLabel); return labels.length ? labels.join("、") : "--"; } diff --git a/apps/admin-web/src/views/ArticleEditorView.vue b/apps/admin-web/src/views/ArticleEditorView.vue index 780bae1..6213c11 100644 --- a/apps/admin-web/src/views/ArticleEditorView.vue +++ b/apps/admin-web/src/views/ArticleEditorView.vue @@ -10,9 +10,12 @@ import { MilkdownProvider } from "@milkdown/vue"; import ArticleEditorCanvas from "@/components/ArticleEditorCanvas.vue"; import PublishArticleModal from "@/components/PublishArticleModal.vue"; import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue"; -import { articlesApi } from "@/lib/api"; +import { articlesApi, mediaApi } from "@/lib/api"; import { formatError } from "@/lib/errors"; -import { getBoundPublishPlatforms } from "@/lib/publish-platforms"; +import { + buildPublishPlatformOptions, + normalizePublishPlatformIds, +} from "@/lib/publish-platforms"; const route = useRoute(); const router = useRouter(); @@ -24,7 +27,6 @@ const title = ref(""); const markdown = ref(""); const initialTitle = ref(""); const initialMarkdown = ref(""); -const boundPublishPlatforms = getBoundPublishPlatforms(); const publishPlatforms = ref([]); const initialPublishPlatforms = ref([]); const coverEnabled = ref(true); @@ -38,6 +40,24 @@ const detailQuery = useQuery({ queryFn: () => articlesApi.detail(articleId.value), }); +const platformsQuery = useQuery({ + queryKey: ["media", "platforms", "article-editor"], + queryFn: () => mediaApi.platforms(), +}); + +const accountsQuery = useQuery({ + queryKey: ["media", "platform-accounts", "article-editor"], + queryFn: () => mediaApi.accounts(), +}); + +const publishPlatformOptions = computed(() => + buildPublishPlatformOptions(platformsQuery.data.value ?? [], accountsQuery.data.value), +); + +const publishPlatformsLoading = computed( + () => platformsQuery.isPending.value || accountsQuery.isPending.value, +); + watch( () => detailQuery.data.value, (detail) => { @@ -52,7 +72,7 @@ watch( markdown.value = nextMarkdown; initialTitle.value = nextTitle; initialMarkdown.value = nextMarkdown; - publishPlatforms.value = [...(detail.platforms ?? [])]; + publishPlatforms.value = normalizePublishPlatformIds(detail.platforms ?? []); initialPublishPlatforms.value = [...publishPlatforms.value]; }, { immediate: true }, @@ -72,7 +92,7 @@ const saveMutation = useMutation({ articlesApi.update(articleId.value, { title: title.value.trim(), markdown_content: markdown.value, - platforms: publishPlatforms.value, + platforms: normalizePublishPlatformIds(publishPlatforms.value), }), onSuccess: async (data) => { message.success(t("article.editor.messages.saved")); @@ -80,7 +100,7 @@ const saveMutation = useMutation({ markdown.value = data.markdown_content ?? ""; initialTitle.value = title.value; initialMarkdown.value = markdown.value; - publishPlatforms.value = [...(data.platforms ?? [])]; + publishPlatforms.value = normalizePublishPlatformIds(data.platforms ?? []); initialPublishPlatforms.value = [...publishPlatforms.value]; await Promise.all([ queryClient.invalidateQueries({ queryKey: ["articles"] }), @@ -232,9 +252,7 @@ function stripLeadingTitleHeading(titleValue: string, markdownValue: string): st } function serializePlatformSelection(platformIds: string[]): string { - return [...platformIds] - .map((platformId) => platformId.trim()) - .filter(Boolean) + return normalizePublishPlatformIds(platformIds) .sort() .join(","); } @@ -287,10 +305,17 @@ function serializePlatformSelection(platformIds: string[]): string {

{{ t("article.editor.platformsTitle") }}

{{ t("article.editor.platformsHint") }}

+