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.
This commit is contained in:
2026-04-07 09:19:03 +08:00
parent 1e844704e4
commit 9f721f6088
13 changed files with 1360 additions and 129 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ReloadOutlined, SendOutlined, PlusOutlined } from "@ant-design/icons-vue";
import { ReloadOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message, notification } from "ant-design-vue";
import type {
@@ -12,7 +12,12 @@ import type {
import { computed, h, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { articlesApi, getApiBaseURL, mediaApi } from "@/lib/api";
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,
@@ -35,32 +40,17 @@ const { t } = useI18n();
const queryClient = useQueryClient();
const selectedAccountIds = ref<number[]>([]);
const coverEnabled = ref(true);
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 fileList = ref<any[]>([]);
const coverPickerOpen = ref(false);
const selectionHydrated = ref(false);
function handleBeforeUpload(file: File) {
const url = URL.createObjectURL(file);
coverAssetUrl.value = url;
fileList.value = [{
uid: file.name,
name: file.name,
status: 'done',
url: url,
}];
return false;
}
function handleRemove() {
coverAssetUrl.value = "";
fileList.value = [];
}
const coverHydrated = ref(false);
const detailQuery = useQuery({
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
@@ -86,20 +76,35 @@ watch(
if (!open) {
selectedAccountIds.value = [];
coverAssetUrl.value = "";
fileList.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 || selectionHydrated.value) {
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;
}
@@ -171,6 +176,25 @@ const accountCards = computed(() => {
});
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 () => {
@@ -180,12 +204,15 @@ const publishMutation = useMutation({
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: coverEnabled.value ? coverAssetUrl.value.trim() || null : null,
cover_asset_url: normalizedCoverValue.value || null,
});
return publishWithPublisherPlugin({
@@ -193,7 +220,7 @@ const publishMutation = useMutation({
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: coverEnabled.value ? coverAssetUrl.value.trim() || null : null,
cover_asset_url: normalizedCoverValue.value || null,
callback_base_url: getApiBaseURL(),
publish_type: "publish",
tasks: batch.tasks,
@@ -228,6 +255,10 @@ const publishMutation = useMutation({
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));
},
});
@@ -264,6 +295,25 @@ 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,
@@ -327,9 +377,9 @@ function showPublishFailures(result: PublisherPublishResponse): void {
<template>
<a-modal
:open="open"
:open="publishModalVisible"
:title="t('media.publish.title')"
:width="650"
:width="920"
:confirm-loading="publishMutation.isPending.value"
@ok="publishMutation.mutate()"
@cancel="emit('update:open', false)"
@@ -415,31 +465,59 @@ function showPublishFailures(result: PublisherPublishResponse): void {
<section class="publish-modal__section">
<div class="publish-modal__section-header inline-header">
<h3>{{ t("media.publish.coverTitle") }}</h3>
<a-switch v-model:checked="coverEnabled" />
<a-switch
:checked="effectiveCoverEnabled"
:disabled="coverRequired"
@change="handleCoverToggle"
/>
</div>
<p class="muted">请保证封面清晰美观和完整</p>
<p class="muted">
{{ coverRequired ? t("media.publish.messages.coverRequired") : t("media.publish.coverHint") }}
</p>
<div class="publish-modal__cover-body" v-if="coverEnabled">
<a-upload
v-model:file-list="fileList"
list-type="picture-card"
class="cover-uploader"
:show-upload-list="true"
:before-upload="handleBeforeUpload"
@remove="handleRemove"
accept="image/*"
<div class="publish-modal__cover-body" v-if="effectiveCoverEnabled">
<button
type="button"
class="publish-modal__cover-preview"
@click="coverPickerOpen = true"
>
<div v-if="fileList.length < 1">
<div class="upload-icon-circle">
<PlusOutlined />
</div>
<div class="upload-text">上传封面图</div>
<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>
</a-upload>
<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>
@@ -649,40 +727,64 @@ function showPublishFailures(result: PublisherPublishResponse): void {
}
.publish-modal__cover-body {
margin-top: 12px;
display: grid;
grid-template-columns: 176px minmax(0, 1fr);
gap: 16px;
margin-top: 14px;
}
.cover-uploader :deep(.ant-upload.ant-upload-select-picture-card) {
width: 160px;
height: 120px;
background-color: #fff;
border: 1px dashed #d9d9d9;
border-radius: 8px;
}
.cover-uploader :deep(.ant-upload-list-picture-card-container) {
width: 160px;
height: 120px;
}
.cover-uploader :deep(.ant-upload-list-item) {
border-radius: 8px;
}
.upload-icon-circle {
width: 28px;
height: 28px;
background-color: #2b303b;
color: #fff;
border-radius: 50%;
.publish-modal__cover-preview {
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 8px;
font-size: 14px;
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;
}
.upload-text {
.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;
color: #595959;
line-height: 1.7;
}
@media (max-width: 860px) {
@@ -693,5 +795,9 @@ function showPublishFailures(result: PublisherPublishResponse): void {
.publish-modal__grid {
grid-template-columns: 1fr;
}
.publish-modal__cover-body {
grid-template-columns: 1fr;
}
}
</style>