Files
geo/apps/admin-web/src/components/PublishArticleModal.vue
T
root 9f721f6088 feat: add cover image functionality for articles
- Implemented `resolveApiURL` function to handle various URL formats.
- Updated `ArticleDetail` and `UpdateArticleRequest` interfaces to include `cover_asset_url`.
- Enhanced `ArticleEditorView` to manage cover image uploads, including a new `CoverPickerModal` component.
- Added cover image requirements logic in `cover-requirements.ts` to enforce platform-specific cover image rules.
- Modified backend services to handle cover image uploads and validations, including checks for required cover images for specific platforms.
- Improved error handling for cover image requirements during article publishing.
2026-04-07 09:19:03 +08:00

804 lines
22 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 } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, notification } from "ant-design-vue";
import type {
MediaPlatform,
PlatformAccount,
PublisherLocalPlatformState,
PublisherPublishResponse,
PublisherPublishTaskResult,
} from "@geo/shared-types";
import { computed, h, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
import { articlesApi, getApiBaseURL, mediaApi, resolveApiURL } from "@/lib/api";
import {
coverUploadRequired,
deriveCoverFileName,
} from "@/lib/cover-requirements";
import { formatError } from "@/lib/errors";
import {
normalizePublishPlatformId,
normalizePublishPlatformIds,
} from "@/lib/publish-platforms";
import { publishWithPublisherPlugin } from "@/lib/publisher-plugin";
import { loadPublisherRuntimeState } from "@/lib/publisher-runtime";
const props = defineProps<{
open: boolean;
articleId: number | null;
}>();
const emit = defineEmits<{
"update:open": [value: boolean];
published: [];
}>();
const { t } = useI18n();
const queryClient = useQueryClient();
const selectedAccountIds = ref<number[]>([]);
const coverEnabled = ref(false);
const coverAssetUrl = ref("");
const coverFileName = ref("");
const runtimeLoading = ref(false);
const pluginInstalled = ref(false);
const pluginVersion = ref<string | undefined>();
const pluginInstallationId = ref<number | null>(null);
const localPlatforms = ref<PublisherLocalPlatformState[]>([]);
const coverPickerOpen = ref(false);
const selectionHydrated = ref(false);
const coverHydrated = ref(false);
const detailQuery = useQuery({
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
enabled: computed(() => props.open && Boolean(props.articleId)),
queryFn: () => articlesApi.detail(props.articleId as number),
});
const accountsQuery = useQuery({
queryKey: ["media", "platform-accounts", "publish-modal"],
enabled: computed(() => props.open),
queryFn: () => mediaApi.accounts(),
});
const platformsQuery = useQuery({
queryKey: ["media", "platforms", "publish-modal"],
enabled: computed(() => props.open),
queryFn: () => mediaApi.platforms(),
});
watch(
() => props.open,
async (open) => {
if (!open) {
selectedAccountIds.value = [];
coverAssetUrl.value = "";
coverFileName.value = "";
coverEnabled.value = false;
pluginInstallationId.value = null;
localPlatforms.value = [];
selectionHydrated.value = false;
coverHydrated.value = false;
return;
}
selectionHydrated.value = false;
coverHydrated.value = false;
await refreshRuntime();
},
{ immediate: true },
);
watchEffect(() => {
if (!props.open) {
return;
}
if (!coverHydrated.value && !detailQuery.isPending.value) {
const initialUrl = resolveApiURL(detailQuery.data.value?.cover_asset_url);
coverAssetUrl.value = initialUrl;
coverFileName.value = deriveCoverFileName(initialUrl);
coverEnabled.value = Boolean(initialUrl);
coverHydrated.value = true;
}
if (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) => [normalizePublishPlatformId(item.platform_id), item]));
});
const accountGroups = computed(() => {
const groups = new Map<string, PlatformAccount[]>();
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) => [normalizePublishPlatformId(platform.platform_id), platform.name]),
);
});
const accountCards = computed(() => {
return (platformsQuery.data.value ?? []).map((platform) => {
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)
: false;
const selectable = Boolean(account && account.status === "active" && uidMatches && pluginInstalled.value);
return {
key: account?.id ?? platformId,
accountId: account?.id ?? null,
platformId,
platformName: platform.name,
platformShortName: platform.short_name,
platformAccentColor: platform.accent_color,
platformCategory: platform.category,
platformUid: account?.platform_uid ?? local?.platform_uid ?? "--",
nickname: account?.nickname ?? local?.nickname ?? t("media.card.unbound"),
status: account?.status ?? "unbound",
local,
selectable,
statusText: resolveAccountStatusText(platform, account, local),
};
});
});
const modalTitle = computed(() => detailQuery.data.value?.title || t("article.untitled"));
const selectedPlatformIds = computed(() =>
accountCards.value
.filter((account) => account.accountId && isSelected(account.accountId))
.map((account) => account.platformId),
);
const publishModalVisible = computed(() => props.open && !coverPickerOpen.value);
const coverRequired = computed(() => coverUploadRequired(selectedPlatformIds.value));
const effectiveCoverEnabled = computed(() => coverRequired.value || coverEnabled.value);
const normalizedCoverValue = computed(() => (effectiveCoverEnabled.value ? coverAssetUrl.value.trim() : ""));
watch(
coverRequired,
(required) => {
if (required) {
coverEnabled.value = true;
}
},
{ immediate: true },
);
const publishMutation = useMutation({
mutationFn: async () => {
if (!props.articleId || !detailQuery.data.value) {
throw new Error("missing_article");
}
if (!selectedAccountIds.value.length) {
throw new Error("no_accounts_selected");
}
if (coverRequired.value && !normalizedCoverValue.value) {
throw new Error("cover_required_for_selected_platforms");
}
const batch = await articlesApi.publishBatch(props.articleId, {
platform_account_ids: selectedAccountIds.value,
plugin_installation_id: pluginInstallationId.value,
publish_type: "publish",
cover_asset_url: normalizedCoverValue.value || null,
});
return publishWithPublisherPlugin({
article_id: detailQuery.data.value.id,
title: detailQuery.data.value.title ?? t("article.untitled"),
markdown_content: detailQuery.data.value.markdown_content ?? "",
html_content: detailQuery.data.value.html_content ?? null,
cover_asset_url: normalizedCoverValue.value || null,
callback_base_url: getApiBaseURL(),
publish_type: "publish",
tasks: batch.tasks,
});
},
onSuccess: async (result) => {
const successCount = result.results.filter((item) => item.success).length;
const failedCount = result.results.length - successCount;
if (failedCount === 0) {
notification.success({
message: t("media.publish.messages.successTitle"),
description: t("media.publish.messages.success", { count: successCount }),
placement: "topRight",
duration: 4.5,
});
} else {
showPublishFailures(result);
}
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
queryClient.invalidateQueries({ queryKey: ["media"] }),
queryClient.invalidateQueries({ queryKey: ["articles", "detail", props.articleId] }),
queryClient.invalidateQueries({ queryKey: ["articles", "publish-records", props.articleId] }),
]);
emit("published");
emit("update:open", false);
},
onError: (error) => {
const normalized = error instanceof Error ? error.message : "";
if (normalized === "no_accounts_selected") {
message.warning(t("media.publish.messages.selectPlatform"));
return;
}
if (normalized === "cover_required_for_selected_platforms") {
message.warning(t("media.publish.messages.coverRequired"));
return;
}
message.error(formatError(error));
},
});
async function refreshRuntime(): Promise<void> {
runtimeLoading.value = true;
try {
const runtime = await loadPublisherRuntimeState();
pluginInstalled.value = runtime.ping.installed;
pluginVersion.value = runtime.ping.version;
pluginInstallationId.value = runtime.pluginInstallationId;
localPlatforms.value = runtime.localPlatforms;
} catch (error) {
pluginInstalled.value = false;
localPlatforms.value = [];
message.error(formatError(error));
} finally {
runtimeLoading.value = false;
}
}
function toggleAccount(accountId: number, selectable: boolean): void {
if (!selectable || publishMutation.isPending.value) {
return;
}
if (selectedAccountIds.value.includes(accountId)) {
selectedAccountIds.value = selectedAccountIds.value.filter((id) => id !== accountId);
return;
}
selectedAccountIds.value = [...selectedAccountIds.value, accountId];
}
function isSelected(accountId: number): boolean {
return selectedAccountIds.value.includes(accountId);
}
function handleCoverToggle(checked: boolean): void {
if (coverRequired.value) {
coverEnabled.value = true;
return;
}
coverEnabled.value = checked;
}
function handleCoverPicked(payload: { url: string; fileName: string }): void {
coverAssetUrl.value = payload.url;
coverFileName.value = payload.fileName;
coverEnabled.value = true;
}
function handleRemoveCover(): void {
coverAssetUrl.value = "";
coverFileName.value = "";
}
function resolveAccountStatusText(
platform: MediaPlatform,
account: PlatformAccount | null,
local?: PublisherLocalPlatformState,
): string {
if (!account) {
return t("media.card.unboundHint", { platform: platform.name });
}
if (!pluginInstalled.value) {
return t("media.plugin.notInstalled");
}
if (!local?.connected) {
return t("media.account.localMissing");
}
if (local.platform_uid && local.platform_uid !== account.platform_uid) {
return t("media.account.localMismatch");
}
return t("media.account.ready");
}
function platformNameForResult(result: PublisherPublishTaskResult): string {
return platformNameMap.value.get(result.platform_id) ?? result.platform_id;
}
function publishFailureText(result: PublisherPublishTaskResult): string {
return t("media.publish.messages.failureItem", {
platform: platformNameForResult(result),
reason: result.message?.trim() || t("media.publish.messages.unknownFailure"),
});
}
function showPublishFailures(result: PublisherPublishResponse): void {
const failedResults = result.results.filter((item) => !item.success);
if (!failedResults.length) {
return;
}
const successCount = result.results.filter((item) => item.success).length;
const failedCount = failedResults.length;
const summary = t("media.publish.messages.partial", {
success: successCount,
failed: failedCount,
});
notification.error({
message: t("media.publish.messages.failureTitle"),
description: h(
"div",
[
h("div", { style: "line-height:1.75;font-weight:500;margin-bottom:8px;" }, summary),
...failedResults.map((item) =>
h("div", { style: "line-height:1.75;" }, publishFailureText(item)),
),
],
),
placement: "topRight",
duration: 8,
});
}
</script>
<template>
<a-modal
:open="publishModalVisible"
:title="t('media.publish.title')"
:width="920"
:confirm-loading="publishMutation.isPending.value"
@ok="publishMutation.mutate()"
@cancel="emit('update:open', false)"
>
<div class="publish-modal">
<section class="publish-modal__hero">
<div class="publish-modal__hero-top">
<span class="eyebrow">标题</span>
<div class="publish-modal__hero-actions">
<a-tag :color="pluginInstalled ? 'success' : 'error'" class="hero-tag">
{{ pluginInstalled ? t("media.plugin.ready") : t("media.plugin.notInstalled") }}
</a-tag>
<a-button :loading="runtimeLoading" @click="refreshRuntime" size="small" class="hero-btn">
<template #icon><ReloadOutlined /></template>
{{ t("media.actions.redetect") }}
</a-button>
</div>
</div>
<h3>{{ modalTitle }}</h3>
</section>
<a-alert
v-if="!pluginInstalled"
type="warning"
show-icon
:message="t('media.plugin.installTitle')"
:description="t('media.plugin.installDesc')"
/>
<section class="publish-modal__section">
<div class="publish-modal__section-header">
<h3><span class="required-star">*</span> {{ t("media.publish.platformsTitle") }}</h3>
<p class="muted">{{ t("media.publish.platformsHint") }}</p>
</div>
<div v-if="accountsQuery.isPending.value || platformsQuery.isPending.value" class="publish-modal__loading">
<a-skeleton active :paragraph="{ rows: 5 }" />
</div>
<div v-else-if="accountCards.length" class="publish-modal__grid">
<button
v-for="account in accountCards"
:key="account.key"
type="button"
class="publish-modal__card"
:class="{
'publish-modal__card--active': account.accountId ? isSelected(account.accountId) : false,
'publish-modal__card--disabled': !account.selectable,
}"
@click="account.accountId ? toggleAccount(account.accountId, account.selectable) : undefined"
>
<div class="publish-modal__card-left">
<span class="publish-modal__check">
<span
v-if="account.accountId && isSelected(account.accountId)"
class="publish-modal__check-inner"
></span>
</span>
<span class="publish-modal__card-badge" :style="{ color: account.platformAccentColor }">
{{ account.platformShortName }}
</span>
<div class="publish-modal__card-text">
<span class="publish-modal__card-name">{{ account.platformName }}</span>
<span class="publish-modal__card-sub" v-if="account.accountId">
{{ account.nickname }}
</span>
<span class="publish-modal__card-sub" v-else>
{{ t("media.card.unbound") }}
</span>
</div>
</div>
<div class="publish-modal__card-right">
<span class="publish-modal__card-status" :class="account.selectable ? 'status-selectable' : 'status-disabled'">
{{ account.selectable ? t("media.account.selectable") : t("media.account.unavailable") }}
</span>
</div>
</button>
</div>
<a-empty v-else :description="t('media.empty.accounts')" />
</section>
<section class="publish-modal__section">
<div class="publish-modal__section-header inline-header">
<h3>{{ t("media.publish.coverTitle") }}</h3>
<a-switch
:checked="effectiveCoverEnabled"
:disabled="coverRequired"
@change="handleCoverToggle"
/>
</div>
<p class="muted">
{{ coverRequired ? t("media.publish.messages.coverRequired") : t("media.publish.coverHint") }}
</p>
<div class="publish-modal__cover-body" v-if="effectiveCoverEnabled">
<button
type="button"
class="publish-modal__cover-preview"
@click="coverPickerOpen = true"
>
<template v-if="coverAssetUrl">
<img :src="coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="publish-modal__cover-plus">+</span>
<span>{{ t("media.publish.coverUpload") }}</span>
</template>
</button>
<div class="publish-modal__cover-side">
<div class="publish-modal__cover-actions">
<a-button type="primary" ghost @click="coverPickerOpen = true">
{{ coverAssetUrl ? t("article.editor.coverReplace") : t("media.publish.coverUpload") }}
</a-button>
<a-button v-if="coverAssetUrl" @click="handleRemoveCover">
{{ t("article.editor.coverRemove") }}
</a-button>
</div>
<div v-if="coverAssetUrl" class="publish-modal__cover-file">
{{ coverFileName || t("article.editor.coverSaved") }}
</div>
</div>
</div>
</section>
</div>
</a-modal>
<CoverPickerModal
v-model:open="coverPickerOpen"
:article-id="detailQuery.data.value?.id ?? null"
:platform-ids="selectedPlatformIds"
:current-url="coverAssetUrl"
:current-file-name="coverFileName"
@confirmed="handleCoverPicked"
/>
</template>
<style scoped>
.publish-modal {
display: flex;
flex-direction: column;
gap: 24px;
}
.publish-modal__hero {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
border: 1px solid #e5e5e5;
border-radius: 12px;
background: #f7f7f7;
}
.publish-modal__hero-top {
display: flex;
align-items: center;
gap: 16px;
justify-content: space-between;
margin-bottom: 12px;
}
.publish-modal__hero h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #262626;
line-height: 1.5;
}
.eyebrow {
font-size: 16px;
text-transform: uppercase;
color: #000000;
margin: 0;
}
.publish-modal__hero-actions {
display: flex;
align-items: center;
gap: 10px;
}
.hero-tag {
margin: 0;
}
.publish-modal__section {
display: flex;
flex-direction: column;
}
.publish-modal__section-header {
margin-bottom: 12px;
}
.publish-modal__section-header.inline-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.publish-modal__section-header h3 {
margin: 0 0 6px 0;
font-size: 15px;
font-weight: 600;
color: #262626;
}
.publish-modal__section-header.inline-header h3 {
margin: 0;
}
.required-star {
color: #ff4d4f;
margin-right: 4px;
font-family: SimSun, sans-serif;
}
.muted {
font-size: 13px;
color: #8c8c8c;
margin: 0;
}
.publish-modal__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 4px;
}
.publish-modal__card {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
transition: all 0.2s ease;
}
.publish-modal__card:hover:not(.publish-modal__card--disabled) {
border-color: #1677ff;
}
.publish-modal__card--active {
border-color: #1677ff;
}
.publish-modal__card--disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: #f7f7f7;
}
.publish-modal__card-left {
display: flex;
align-items: center;
gap: 12px;
}
.publish-modal__check {
width: 16px;
height: 16px;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
background: #fff;
transition: all 0.2s;
}
.publish-modal__card--active .publish-modal__check {
background-color: #1677ff;
border-color: #1677ff;
}
.publish-modal__check-inner {
width: 8px;
height: 8px;
background-color: transparent;
border: 2px solid #fff;
border-top: 0;
border-left: 0;
transform: rotate(45deg) scale(1) translate(-1px, -1px);
display: block;
}
.publish-modal__card-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
background: #f7f7f7;
border: 1px solid #e5e5e5;
font-size: 12px;
font-weight: 700;
}
.publish-modal__card-text {
display: flex;
flex-direction: column;
}
.publish-modal__card-name {
font-size: 14px;
font-weight: 500;
color: #262626;
line-height: 1.3;
}
.publish-modal__card-sub {
font-size: 12px;
font-weight: 400;
color: #8c8c8c;
margin-top: 2px;
}
.publish-modal__card-status {
font-size: 13px;
}
.status-selectable {
color: #52c41a;
}
.status-disabled {
color: #1677ff;
}
.publish-modal__loading {
padding: 12px 0;
}
.publish-modal__cover-body {
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
gap: 16px;
margin-top: 14px;
}
.publish-modal__cover-preview {
display: flex;
align-items: center;
justify-content: center;
min-height: 124px;
padding: 0;
border: 1px dashed #d3dceb;
border-radius: 20px;
background:
radial-gradient(circle at top, rgba(70, 102, 255, 0.08), transparent 45%),
#fbfcff;
color: #475467;
cursor: pointer;
overflow: hidden;
}
.publish-modal__cover-preview img {
width: 100%;
height: 124px;
object-fit: cover;
}
.publish-modal__cover-plus {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-right: 8px;
border-radius: 999px;
background: #eef4ff;
color: #355dff;
font-size: 28px;
line-height: 1;
}
.publish-modal__cover-side {
display: flex;
flex-direction: column;
gap: 12px;
}
.publish-modal__cover-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.publish-modal__cover-file {
color: #667085;
font-size: 13px;
line-height: 1.7;
}
@media (max-width: 860px) {
.publish-modal__hero {
flex-direction: column;
}
.publish-modal__grid {
grid-template-columns: 1fr;
}
.publish-modal__cover-body {
grid-template-columns: 1fr;
}
}
</style>