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
+132 -51
View File
@@ -2,15 +2,20 @@
import { LeftOutlined } from "@ant-design/icons-vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { MilkdownProvider } from "@milkdown/vue";
import ArticleEditorCanvas from "@/components/ArticleEditorCanvas.vue";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
import PublishArticleModal from "@/components/PublishArticleModal.vue";
import PublishPlatformSelector from "@/components/PublishPlatformSelector.vue";
import { articlesApi, mediaApi } from "@/lib/api";
import { articlesApi, mediaApi, resolveApiURL } from "@/lib/api";
import {
coverUploadRequired,
deriveCoverFileName,
} from "@/lib/cover-requirements";
import { formatError } from "@/lib/errors";
import {
buildPublishPlatformOptions,
@@ -29,8 +34,11 @@ const initialTitle = ref("");
const initialMarkdown = ref("");
const publishPlatforms = ref<string[]>([]);
const initialPublishPlatforms = ref<string[]>([]);
const coverEnabled = ref(true);
const coverPreviewUrl = ref("");
const coverEnabled = ref(false);
const coverAssetUrl = ref("");
const coverFileName = ref("");
const initialCoverAssetUrl = ref("");
const coverPickerOpen = ref(false);
const leaveModalOpen = ref(false);
const publishModalOpen = ref(false);
@@ -74,17 +82,39 @@ watch(
initialMarkdown.value = nextMarkdown;
publishPlatforms.value = normalizePublishPlatformIds(detail.platforms ?? []);
initialPublishPlatforms.value = [...publishPlatforms.value];
coverAssetUrl.value = resolveApiURL(detail.cover_asset_url);
coverFileName.value = deriveCoverFileName(coverAssetUrl.value);
initialCoverAssetUrl.value = coverAssetUrl.value;
coverEnabled.value = Boolean(coverAssetUrl.value);
},
{ immediate: true },
);
const detail = computed(() => detailQuery.data.value);
const editorLocked = computed(() => detail.value?.generate_status !== "completed");
const coverRequired = computed(() => coverUploadRequired(publishPlatforms.value));
const normalizedCoverValue = computed(() => {
if (!coverEnabled.value && !coverRequired.value) {
return "";
}
return coverAssetUrl.value.trim();
});
const hasChanges = computed(
() =>
title.value.trim() !== initialTitle.value.trim() ||
markdown.value !== initialMarkdown.value ||
serializePlatformSelection(publishPlatforms.value) !== serializePlatformSelection(initialPublishPlatforms.value),
serializePlatformSelection(publishPlatforms.value) !== serializePlatformSelection(initialPublishPlatforms.value) ||
normalizedCoverValue.value !== initialCoverAssetUrl.value.trim(),
);
watch(
coverRequired,
(required) => {
if (required) {
coverEnabled.value = true;
}
},
{ immediate: true },
);
const saveMutation = useMutation({
@@ -93,6 +123,7 @@ const saveMutation = useMutation({
title: title.value.trim(),
markdown_content: markdown.value,
platforms: normalizePublishPlatformIds(publishPlatforms.value),
cover_asset_url: normalizedCoverValue.value,
}),
onSuccess: async (data) => {
message.success(t("article.editor.messages.saved"));
@@ -102,6 +133,10 @@ const saveMutation = useMutation({
initialMarkdown.value = markdown.value;
publishPlatforms.value = normalizePublishPlatformIds(data.platforms ?? []);
initialPublishPlatforms.value = [...publishPlatforms.value];
coverAssetUrl.value = resolveApiURL(data.cover_asset_url);
coverFileName.value = deriveCoverFileName(coverAssetUrl.value);
initialCoverAssetUrl.value = coverAssetUrl.value;
coverEnabled.value = coverRequired.value || Boolean(coverAssetUrl.value);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["articles"] }),
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
@@ -163,6 +198,17 @@ async function uploadEditorImage(file: File): Promise<string> {
return result.url;
}
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 = "";
}
async function handlePublished(): Promise<void> {
await Promise.all([
detailQuery.refetch(),
@@ -248,26 +294,6 @@ async function handleSaveAndLeave(): Promise<void> {
navigateBack();
}
function handleCoverChange(event: Event): void {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) {
return;
}
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value);
}
coverPreviewUrl.value = URL.createObjectURL(file);
}
onBeforeUnmount(() => {
if (coverPreviewUrl.value) {
URL.revokeObjectURL(coverPreviewUrl.value);
}
});
function stripLeadingTitleHeading(titleValue: string, markdownValue: string): string {
const normalizedTitle = titleValue.trim();
if (!normalizedTitle) {
@@ -361,30 +387,51 @@ function serializePlatformSelection(platformIds: string[]): string {
<div class="article-editor-view__card-head">
<div>
<h3>{{ t("article.editor.coverTitle") }}</h3>
<p>{{ t("article.editor.coverHint") }}</p>
<p>{{ coverRequired ? t("article.editor.coverRequired") : t("article.editor.coverHint") }}</p>
</div>
<a-switch v-model:checked="coverEnabled" />
<a-switch
v-model:checked="coverEnabled"
:disabled="coverRequired"
/>
</div>
<label
class="article-editor-view__cover-dropzone"
:class="{ 'article-editor-view__cover-dropzone--disabled': !coverEnabled }"
<div
v-if="coverEnabled || coverRequired"
class="article-editor-view__cover-panel"
>
<input
type="file"
accept="image/*"
:disabled="!coverEnabled"
hidden
@change="handleCoverChange"
/>
<template v-if="coverPreviewUrl">
<img :src="coverPreviewUrl" alt="cover preview" />
</template>
<template v-else>
<span class="article-editor-view__cover-plus">+</span>
<span>{{ t("article.editor.coverUpload") }}</span>
</template>
</label>
<button
type="button"
class="article-editor-view__cover-dropzone"
@click="coverPickerOpen = true"
>
<template v-if="coverAssetUrl">
<img :src="coverAssetUrl" alt="cover preview" />
</template>
<template v-else>
<span class="article-editor-view__cover-plus">+</span>
<span>{{ t("article.editor.coverUpload") }}</span>
</template>
</button>
<div class="article-editor-view__cover-info">
<div class="article-editor-view__cover-actions">
<a-button type="primary" ghost @click="coverPickerOpen = true">
{{ coverAssetUrl ? t("article.editor.coverReplace") : t("article.editor.coverUpload") }}
</a-button>
<a-button v-if="coverAssetUrl" @click="handleRemoveCover">
{{ t("article.editor.coverRemove") }}
</a-button>
</div>
<div v-if="coverAssetUrl" class="article-editor-view__cover-file">
{{ coverFileName || t("article.editor.coverSaved") }}
</div>
</div>
</div>
<div v-else class="article-editor-view__cover-off">
{{ t("article.editor.coverOff") }}
</div>
</section>
</aside>
</div>
@@ -426,6 +473,15 @@ function serializePlatformSelection(platformIds: string[]): string {
:article-id="detail?.id ?? null"
@published="handlePublished"
/>
<CoverPickerModal
v-model:open="coverPickerOpen"
:article-id="detail?.id ?? null"
:platform-ids="publishPlatforms"
:current-url="coverAssetUrl"
:current-file-name="coverFileName"
@confirmed="handleCoverPicked"
/>
</div>
</template>
@@ -546,14 +602,21 @@ function serializePlatformSelection(platformIds: string[]): string {
gap: 12px;
}
.article-editor-view__cover-panel {
display: grid;
grid-template-columns: 156px minmax(0, 1fr);
gap: 16px;
margin-top: 18px;
}
.article-editor-view__cover-dropzone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 176px;
margin-top: 18px;
min-height: 116px;
padding: 0;
border: 1px dashed #d3dceb;
border-radius: 20px;
background:
@@ -566,13 +629,27 @@ function serializePlatformSelection(platformIds: string[]): string {
.article-editor-view__cover-dropzone img {
width: 100%;
height: 176px;
height: 116px;
object-fit: cover;
}
.article-editor-view__cover-dropzone--disabled {
opacity: 0.48;
cursor: not-allowed;
.article-editor-view__cover-info {
display: flex;
flex-direction: column;
gap: 12px;
}
.article-editor-view__cover-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.article-editor-view__cover-file,
.article-editor-view__cover-off {
color: #667085;
font-size: 13px;
line-height: 1.7;
}
.article-editor-view__cover-plus {
@@ -596,6 +673,10 @@ function serializePlatformSelection(platformIds: string[]): string {
.article-editor-view__rail {
order: -1;
}
.article-editor-view__cover-panel {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {