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:
@@ -0,0 +1,782 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CloseOutlined, CloudUploadOutlined, PlusOutlined } from "@ant-design/icons-vue";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
|
import { articlesApi } from "@/lib/api";
|
||||||
|
import {
|
||||||
|
deriveCoverFileName,
|
||||||
|
getPrimaryCoverRequirement,
|
||||||
|
type PlatformCoverRequirement,
|
||||||
|
} from "@/lib/cover-requirements";
|
||||||
|
import { formatError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
articleId: number | null;
|
||||||
|
platformIds: string[];
|
||||||
|
currentUrl?: string | null;
|
||||||
|
currentFileName?: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:open": [value: boolean];
|
||||||
|
confirmed: [payload: { url: string; fileName: string }];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
|
const activeTab = ref("local");
|
||||||
|
const workingUrl = ref("");
|
||||||
|
const workingFileName = ref("");
|
||||||
|
const sourceKind = ref<"remote" | "local" | null>(null);
|
||||||
|
const naturalWidth = ref(0);
|
||||||
|
const naturalHeight = ref(0);
|
||||||
|
const zoom = ref(1);
|
||||||
|
const offsetX = ref(0);
|
||||||
|
const offsetY = ref(0);
|
||||||
|
const confirmLoading = ref(false);
|
||||||
|
const transformDirty = ref(false);
|
||||||
|
const localObjectUrl = ref("");
|
||||||
|
const localFile = ref<File | null>(null);
|
||||||
|
|
||||||
|
const dragging = ref(false);
|
||||||
|
const dragStartX = ref(0);
|
||||||
|
const dragStartY = ref(0);
|
||||||
|
const dragOriginX = ref(0);
|
||||||
|
const dragOriginY = ref(0);
|
||||||
|
const imageLoadToken = ref(0);
|
||||||
|
|
||||||
|
const primaryRequirement = computed(() => getPrimaryCoverRequirement(props.platformIds));
|
||||||
|
const aspectRatioLocked = computed(() => Boolean(primaryRequirement.value.aspectRatio));
|
||||||
|
const localTabLabel = computed(() =>
|
||||||
|
workingUrl.value ? `${t("coverPicker.tabs.local")} (1)` : t("coverPicker.tabs.local"),
|
||||||
|
);
|
||||||
|
const hasImage = computed(() => Boolean(workingUrl.value));
|
||||||
|
const previewHint = computed(() =>
|
||||||
|
aspectRatioLocked.value ? t("coverPicker.previewHintLocked") : t("coverPicker.previewHintFree"),
|
||||||
|
);
|
||||||
|
const stageSize = computed(() => {
|
||||||
|
const ratio = primaryRequirement.value.aspectRatio
|
||||||
|
|| (naturalWidth.value > 0 && naturalHeight.value > 0 ? naturalWidth.value / naturalHeight.value : 4 / 3);
|
||||||
|
const maxWidth = 720;
|
||||||
|
const maxHeight = 420;
|
||||||
|
|
||||||
|
let width = maxWidth;
|
||||||
|
let height = width / ratio;
|
||||||
|
|
||||||
|
if (height > maxHeight) {
|
||||||
|
height = maxHeight;
|
||||||
|
width = height * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.round(width),
|
||||||
|
height: Math.round(height),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayMetrics = computed(() => {
|
||||||
|
if (!naturalWidth.value || !naturalHeight.value) {
|
||||||
|
return {
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
baseScale: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseScale = Math.max(
|
||||||
|
stageSize.value.width / naturalWidth.value,
|
||||||
|
stageSize.value.height / naturalHeight.value,
|
||||||
|
);
|
||||||
|
const scaled = baseScale * zoom.value;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: naturalWidth.value * scaled,
|
||||||
|
height: naturalHeight.value * scaled,
|
||||||
|
baseScale,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const stageImageStyle = computed(() => ({
|
||||||
|
width: `${displayMetrics.value.width}px`,
|
||||||
|
height: `${displayMetrics.value.height}px`,
|
||||||
|
transform: `translate(calc(-50% + ${offsetX.value}px), calc(-50% + ${offsetY.value}px))`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stageFrameStyle = computed(() => ({
|
||||||
|
width: `${stageSize.value.width}px`,
|
||||||
|
height: `${stageSize.value.height}px`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(open) => {
|
||||||
|
if (open) {
|
||||||
|
hydrateFromProps();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopDragging();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.currentUrl, props.currentFileName],
|
||||||
|
() => {
|
||||||
|
if (props.open && sourceKind.value !== "local") {
|
||||||
|
hydrateFromProps();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.platformIds.join(","),
|
||||||
|
() => {
|
||||||
|
if (!props.open || !hasImage.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resetTransform();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [zoom.value, naturalWidth.value, naturalHeight.value, stageSize.value.width, stageSize.value.height],
|
||||||
|
() => {
|
||||||
|
clampOffsets();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopDragging();
|
||||||
|
revokeLocalObjectUrl();
|
||||||
|
});
|
||||||
|
|
||||||
|
function hydrateFromProps(): void {
|
||||||
|
activeTab.value = "local";
|
||||||
|
transformDirty.value = false;
|
||||||
|
const nextUrl = String(props.currentUrl ?? "").trim();
|
||||||
|
const nextFileName = String(props.currentFileName ?? "").trim() || deriveCoverFileName(nextUrl);
|
||||||
|
|
||||||
|
workingUrl.value = nextUrl;
|
||||||
|
workingFileName.value = nextFileName;
|
||||||
|
sourceKind.value = nextUrl ? "remote" : null;
|
||||||
|
localFile.value = null;
|
||||||
|
|
||||||
|
if (!nextUrl) {
|
||||||
|
resetStage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadImageMeta(nextUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetStage(): void {
|
||||||
|
naturalWidth.value = 0;
|
||||||
|
naturalHeight.value = 0;
|
||||||
|
resetTransform();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetTransform(): void {
|
||||||
|
zoom.value = 1;
|
||||||
|
offsetX.value = 0;
|
||||||
|
offsetY.value = 0;
|
||||||
|
transformDirty.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampOffsets(): void {
|
||||||
|
const maxX = Math.max(0, (displayMetrics.value.width - stageSize.value.width) / 2);
|
||||||
|
const maxY = Math.max(0, (displayMetrics.value.height - stageSize.value.height) / 2);
|
||||||
|
|
||||||
|
offsetX.value = clamp(offsetX.value, -maxX, maxX);
|
||||||
|
offsetY.value = clamp(offsetY.value, -maxY, maxY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTransformDirty(): void {
|
||||||
|
transformDirty.value = zoom.value !== 1 || offsetX.value !== 0 || offsetY.value !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImageMeta(url: string): Promise<void> {
|
||||||
|
const token = imageLoadToken.value + 1;
|
||||||
|
imageLoadToken.value = token;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await loadImageElement(url);
|
||||||
|
if (token !== imageLoadToken.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
naturalWidth.value = image.naturalWidth;
|
||||||
|
naturalHeight.value = image.naturalHeight;
|
||||||
|
resetTransform();
|
||||||
|
} catch {
|
||||||
|
if (token !== imageLoadToken.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetStage();
|
||||||
|
message.warning(t("coverPicker.messages.imageLoadFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFileDialog(): void {
|
||||||
|
fileInput.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileChange(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = "";
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) {
|
||||||
|
message.warning(t("coverPicker.messages.invalidType"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
message.warning(t("coverPicker.messages.tooLarge"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeLocalObjectUrl();
|
||||||
|
const objectUrl = URL.createObjectURL(file);
|
||||||
|
localObjectUrl.value = objectUrl;
|
||||||
|
workingUrl.value = objectUrl;
|
||||||
|
workingFileName.value = file.name;
|
||||||
|
sourceKind.value = "local";
|
||||||
|
localFile.value = file;
|
||||||
|
transformDirty.value = false;
|
||||||
|
void loadImageMeta(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeLocalObjectUrl(): void {
|
||||||
|
if (!localObjectUrl.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
URL.revokeObjectURL(localObjectUrl.value);
|
||||||
|
localObjectUrl.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleStageMouseDown(event: MouseEvent): void {
|
||||||
|
if (!hasImage.value || confirmLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging.value = true;
|
||||||
|
dragStartX.value = event.clientX;
|
||||||
|
dragStartY.value = event.clientY;
|
||||||
|
dragOriginX.value = offsetX.value;
|
||||||
|
dragOriginY.value = offsetY.value;
|
||||||
|
window.addEventListener("mousemove", handleWindowMouseMove);
|
||||||
|
window.addEventListener("mouseup", handleWindowMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowMouseMove(event: MouseEvent): void {
|
||||||
|
if (!dragging.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offsetX.value = dragOriginX.value + event.clientX - dragStartX.value;
|
||||||
|
offsetY.value = dragOriginY.value + event.clientY - dragStartY.value;
|
||||||
|
clampOffsets();
|
||||||
|
markTransformDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowMouseUp(): void {
|
||||||
|
stopDragging();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDragging(): void {
|
||||||
|
if (!dragging.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dragging.value = false;
|
||||||
|
window.removeEventListener("mousemove", handleWindowMouseMove);
|
||||||
|
window.removeEventListener("mouseup", handleWindowMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomChange(value: number | [number, number]): void {
|
||||||
|
const nextValue = Array.isArray(value) ? value[0] : value;
|
||||||
|
zoom.value = clamp(nextValue, 1, 2.4);
|
||||||
|
clampOffsets();
|
||||||
|
markTransformDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear(): void {
|
||||||
|
if (confirmLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
workingUrl.value = "";
|
||||||
|
workingFileName.value = "";
|
||||||
|
sourceKind.value = null;
|
||||||
|
localFile.value = null;
|
||||||
|
transformDirty.value = false;
|
||||||
|
resetStage();
|
||||||
|
revokeLocalObjectUrl();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfirm(): Promise<void> {
|
||||||
|
if (!workingUrl.value) {
|
||||||
|
message.warning(t("coverPicker.messages.missingImage"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!props.articleId) {
|
||||||
|
message.error(t("coverPicker.messages.missingArticle"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sourceKind.value !== "local" && !transformDirty.value && !aspectRatioLocked.value) {
|
||||||
|
emit("confirmed", {
|
||||||
|
url: workingUrl.value,
|
||||||
|
fileName: workingFileName.value || deriveCoverFileName(workingUrl.value) || "cover.png",
|
||||||
|
});
|
||||||
|
emit("update:open", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmLoading.value = true;
|
||||||
|
try {
|
||||||
|
if (sourceKind.value === "local" && !transformDirty.value && !aspectRatioLocked.value && localFile.value) {
|
||||||
|
const uploaded = await articlesApi.uploadImage(props.articleId, localFile.value);
|
||||||
|
emit("confirmed", {
|
||||||
|
url: uploaded.url,
|
||||||
|
fileName: localFile.value.name,
|
||||||
|
});
|
||||||
|
emit("update:open", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await renderCroppedBlob(primaryRequirement.value);
|
||||||
|
const fileName = ensureImageExtension(
|
||||||
|
workingFileName.value || deriveCoverFileName(workingUrl.value) || "cover.png",
|
||||||
|
blob.type,
|
||||||
|
);
|
||||||
|
const file = new File([blob], fileName, { type: blob.type });
|
||||||
|
const uploaded = await articlesApi.uploadImage(props.articleId, file);
|
||||||
|
|
||||||
|
emit("confirmed", {
|
||||||
|
url: uploaded.url,
|
||||||
|
fileName,
|
||||||
|
});
|
||||||
|
emit("update:open", false);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(formatError(error));
|
||||||
|
} finally {
|
||||||
|
confirmLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderCroppedBlob(requirement: PlatformCoverRequirement): Promise<Blob> {
|
||||||
|
const image = await loadImageElement(workingUrl.value);
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = requirement.outputWidth ?? Math.max(Math.round(stageSize.value.width * 2), 1);
|
||||||
|
canvas.height = requirement.outputHeight ?? Math.max(Math.round(stageSize.value.height * 2), 1);
|
||||||
|
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("article_image_upload_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseScale = Math.max(
|
||||||
|
stageSize.value.width / image.naturalWidth,
|
||||||
|
stageSize.value.height / image.naturalHeight,
|
||||||
|
);
|
||||||
|
const renderedWidth = image.naturalWidth * baseScale * zoom.value;
|
||||||
|
const renderedHeight = image.naturalHeight * baseScale * zoom.value;
|
||||||
|
const outputScale = canvas.width / stageSize.value.width;
|
||||||
|
|
||||||
|
context.fillStyle = "#ffffff";
|
||||||
|
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
context.drawImage(
|
||||||
|
image,
|
||||||
|
(stageSize.value.width / 2 - renderedWidth / 2 + offsetX.value) * outputScale,
|
||||||
|
(stageSize.value.height / 2 - renderedHeight / 2 + offsetY.value) * outputScale,
|
||||||
|
renderedWidth * outputScale,
|
||||||
|
renderedHeight * outputScale,
|
||||||
|
);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => {
|
||||||
|
canvas.toBlob(resolve, "image/png", 0.92);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error("article_image_upload_failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureImageExtension(fileName: string, mimeType: string): string {
|
||||||
|
const trimmed = fileName.trim();
|
||||||
|
const normalizedMime = mimeType.toLowerCase();
|
||||||
|
const extension = normalizedMime.includes("webp")
|
||||||
|
? "webp"
|
||||||
|
: normalizedMime.includes("gif")
|
||||||
|
? "gif"
|
||||||
|
: normalizedMime.includes("jpeg")
|
||||||
|
? "jpg"
|
||||||
|
: "png";
|
||||||
|
|
||||||
|
if (/\.(png|jpe?g|gif|webp)$/i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${trimmed || "cover"}.${extension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(): void {
|
||||||
|
emit("update:open", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImageElement(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error("article_image_upload_failed"));
|
||||||
|
image.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
:open="open"
|
||||||
|
:width="900"
|
||||||
|
:confirm-loading="confirmLoading"
|
||||||
|
:ok-text="t('common.confirm')"
|
||||||
|
:cancel-text="t('common.cancel')"
|
||||||
|
:title="t('coverPicker.title')"
|
||||||
|
@ok="handleConfirm"
|
||||||
|
@cancel="closeModal"
|
||||||
|
>
|
||||||
|
<div class="cover-picker">
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
|
hidden
|
||||||
|
@change="handleFileChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-tabs v-model:activeKey="activeTab" class="cover-picker__tabs">
|
||||||
|
<a-tab-pane key="local" :tab="localTabLabel">
|
||||||
|
<div class="cover-picker__body">
|
||||||
|
<section class="cover-picker__workspace">
|
||||||
|
<div class="cover-picker__stage-panel">
|
||||||
|
<div
|
||||||
|
class="cover-picker__stage-frame"
|
||||||
|
:class="{ 'cover-picker__stage-frame--dragging': dragging }"
|
||||||
|
:style="stageFrameStyle"
|
||||||
|
@mousedown.prevent="handleStageMouseDown"
|
||||||
|
>
|
||||||
|
<template v-if="hasImage">
|
||||||
|
<img
|
||||||
|
class="cover-picker__stage-image"
|
||||||
|
:src="workingUrl"
|
||||||
|
:style="stageImageStyle"
|
||||||
|
alt="cover preview"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div class="cover-picker__stage-outline"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="cover-picker__stage-empty"
|
||||||
|
type="button"
|
||||||
|
@click="openFileDialog"
|
||||||
|
>
|
||||||
|
<span class="cover-picker__stage-empty-icon">
|
||||||
|
<CloudUploadOutlined />
|
||||||
|
</span>
|
||||||
|
<strong>点击本地上传</strong>
|
||||||
|
<small>支持jpg、png等格式,单张图片最大支持5M,清晰的图片更有利于推荐</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasImage" class="cover-picker__controls">
|
||||||
|
<span>{{ t("coverPicker.zoom") }}</span>
|
||||||
|
<a-slider
|
||||||
|
class="cover-picker__slider"
|
||||||
|
:min="1"
|
||||||
|
:max="2.4"
|
||||||
|
:step="0.05"
|
||||||
|
:value="zoom"
|
||||||
|
:disabled="!hasImage"
|
||||||
|
@change="handleZoomChange"
|
||||||
|
/>
|
||||||
|
<strong>{{ Math.round(zoom * 100) }}%</strong>
|
||||||
|
<a-button size="small" @click="resetTransform" style="margin-left: 16px;">
|
||||||
|
{{ t("coverPicker.actions.reset") }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasImage" class="cover-picker__thumbs">
|
||||||
|
<button class="cover-picker__upload-tile" type="button" @click="openFileDialog">
|
||||||
|
<span class="cover-picker__upload-tile-icon">
|
||||||
|
<PlusOutlined />
|
||||||
|
</span>
|
||||||
|
<span>{{ t("coverPicker.actions.upload") }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="hasImage"
|
||||||
|
class="cover-picker__thumb cover-picker__thumb--active"
|
||||||
|
>
|
||||||
|
<img :src="workingUrl" alt="cover thumbnail" />
|
||||||
|
<button type="button" class="cover-picker__thumb-close" @click="handleClear">
|
||||||
|
<CloseOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
|
||||||
|
<a-tab-pane key="ai" :tab="t('coverPicker.tabs.ai')">
|
||||||
|
<div class="cover-picker__ai-placeholder">
|
||||||
|
<strong>{{ t("coverPicker.aiTitle") }}</strong>
|
||||||
|
<p>{{ t("coverPicker.aiHint") }}</p>
|
||||||
|
</div>
|
||||||
|
</a-tab-pane>
|
||||||
|
</a-tabs>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cover-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__tabs :deep(.ant-tabs-nav) {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__body {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__workspace,
|
||||||
|
.cover-picker__ai-placeholder {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__workspace {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__stage-panel {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__stage-frame {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__stage-frame--dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__stage-image {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
max-width: none;
|
||||||
|
object-fit: cover;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__stage-outline {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 3px solid #3b5bff;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px rgba(255, 255, 255, 0.65),
|
||||||
|
0 0 0 999px rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__stage-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #475467;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__ai-placeholder strong,
|
||||||
|
.cover-picker__stage-empty strong {
|
||||||
|
margin: 0;
|
||||||
|
color: #111827;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__ai-placeholder p,
|
||||||
|
.cover-picker__stage-empty small {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667085;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__stage-empty-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e9ecef;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 40px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__upload-tile-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(53, 93, 255, 0.12);
|
||||||
|
color: #355dff;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 18px;
|
||||||
|
color: #475467;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__slider {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__thumbs {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 20px;
|
||||||
|
min-height: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__upload-tile,
|
||||||
|
.cover-picker__thumb {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 124px;
|
||||||
|
height: 124px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(219, 228, 242, 0.92);
|
||||||
|
background: #fff;
|
||||||
|
color: #475467;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__upload-tile {
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__thumb {
|
||||||
|
position: relative;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(53, 93, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__thumb-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.45);
|
||||||
|
color: #ffffff !important;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 11px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
transition: background-color 0.2s, transform 0.2s;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__thumb-close :deep(.anticon),
|
||||||
|
.cover-picker__thumb-close :deep(svg) {
|
||||||
|
color: #ffffff !important;
|
||||||
|
fill: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__thumb-close:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.65);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__ai-placeholder {
|
||||||
|
padding: 28px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.cover-picker__thumbs {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-picker__remove {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message, notification } from "ant-design-vue";
|
import { message, notification } from "ant-design-vue";
|
||||||
import type {
|
import type {
|
||||||
@@ -12,7 +12,12 @@ import type {
|
|||||||
import { computed, h, ref, watch, watchEffect } from "vue";
|
import { computed, h, ref, watch, watchEffect } from "vue";
|
||||||
import { useI18n } from "vue-i18n";
|
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 { formatError } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
normalizePublishPlatformId,
|
normalizePublishPlatformId,
|
||||||
@@ -35,32 +40,17 @@ const { t } = useI18n();
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const selectedAccountIds = ref<number[]>([]);
|
const selectedAccountIds = ref<number[]>([]);
|
||||||
const coverEnabled = ref(true);
|
const coverEnabled = ref(false);
|
||||||
const coverAssetUrl = ref("");
|
const coverAssetUrl = ref("");
|
||||||
|
const coverFileName = ref("");
|
||||||
const runtimeLoading = ref(false);
|
const runtimeLoading = ref(false);
|
||||||
const pluginInstalled = ref(false);
|
const pluginInstalled = ref(false);
|
||||||
const pluginVersion = ref<string | undefined>();
|
const pluginVersion = ref<string | undefined>();
|
||||||
const pluginInstallationId = ref<number | null>(null);
|
const pluginInstallationId = ref<number | null>(null);
|
||||||
const localPlatforms = ref<PublisherLocalPlatformState[]>([]);
|
const localPlatforms = ref<PublisherLocalPlatformState[]>([]);
|
||||||
const fileList = ref<any[]>([]);
|
const coverPickerOpen = ref(false);
|
||||||
const selectionHydrated = ref(false);
|
const selectionHydrated = ref(false);
|
||||||
|
const coverHydrated = 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 detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
|
queryKey: computed(() => ["articles", "detail", props.articleId, "publish-modal"]),
|
||||||
@@ -86,20 +76,35 @@ watch(
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
selectedAccountIds.value = [];
|
selectedAccountIds.value = [];
|
||||||
coverAssetUrl.value = "";
|
coverAssetUrl.value = "";
|
||||||
fileList.value = [];
|
coverFileName.value = "";
|
||||||
|
coverEnabled.value = false;
|
||||||
pluginInstallationId.value = null;
|
pluginInstallationId.value = null;
|
||||||
localPlatforms.value = [];
|
localPlatforms.value = [];
|
||||||
selectionHydrated.value = false;
|
selectionHydrated.value = false;
|
||||||
|
coverHydrated.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selectionHydrated.value = false;
|
selectionHydrated.value = false;
|
||||||
|
coverHydrated.value = false;
|
||||||
await refreshRuntime();
|
await refreshRuntime();
|
||||||
},
|
},
|
||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
watchEffect(() => {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +176,25 @@ const accountCards = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const modalTitle = computed(() => detailQuery.data.value?.title || t("article.untitled"));
|
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({
|
const publishMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -180,12 +204,15 @@ const publishMutation = useMutation({
|
|||||||
if (!selectedAccountIds.value.length) {
|
if (!selectedAccountIds.value.length) {
|
||||||
throw new Error("no_accounts_selected");
|
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, {
|
const batch = await articlesApi.publishBatch(props.articleId, {
|
||||||
platform_account_ids: selectedAccountIds.value,
|
platform_account_ids: selectedAccountIds.value,
|
||||||
plugin_installation_id: pluginInstallationId.value,
|
plugin_installation_id: pluginInstallationId.value,
|
||||||
publish_type: "publish",
|
publish_type: "publish",
|
||||||
cover_asset_url: coverEnabled.value ? coverAssetUrl.value.trim() || null : null,
|
cover_asset_url: normalizedCoverValue.value || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return publishWithPublisherPlugin({
|
return publishWithPublisherPlugin({
|
||||||
@@ -193,7 +220,7 @@ const publishMutation = useMutation({
|
|||||||
title: detailQuery.data.value.title ?? t("article.untitled"),
|
title: detailQuery.data.value.title ?? t("article.untitled"),
|
||||||
markdown_content: detailQuery.data.value.markdown_content ?? "",
|
markdown_content: detailQuery.data.value.markdown_content ?? "",
|
||||||
html_content: detailQuery.data.value.html_content ?? null,
|
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(),
|
callback_base_url: getApiBaseURL(),
|
||||||
publish_type: "publish",
|
publish_type: "publish",
|
||||||
tasks: batch.tasks,
|
tasks: batch.tasks,
|
||||||
@@ -228,6 +255,10 @@ const publishMutation = useMutation({
|
|||||||
message.warning(t("media.publish.messages.selectPlatform"));
|
message.warning(t("media.publish.messages.selectPlatform"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (normalized === "cover_required_for_selected_platforms") {
|
||||||
|
message.warning(t("media.publish.messages.coverRequired"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
message.error(formatError(error));
|
message.error(formatError(error));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -264,6 +295,25 @@ function isSelected(accountId: number): boolean {
|
|||||||
return selectedAccountIds.value.includes(accountId);
|
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(
|
function resolveAccountStatusText(
|
||||||
platform: MediaPlatform,
|
platform: MediaPlatform,
|
||||||
account: PlatformAccount | null,
|
account: PlatformAccount | null,
|
||||||
@@ -327,9 +377,9 @@ function showPublishFailures(result: PublisherPublishResponse): void {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-modal
|
<a-modal
|
||||||
:open="open"
|
:open="publishModalVisible"
|
||||||
:title="t('media.publish.title')"
|
:title="t('media.publish.title')"
|
||||||
:width="650"
|
:width="920"
|
||||||
:confirm-loading="publishMutation.isPending.value"
|
:confirm-loading="publishMutation.isPending.value"
|
||||||
@ok="publishMutation.mutate()"
|
@ok="publishMutation.mutate()"
|
||||||
@cancel="emit('update:open', false)"
|
@cancel="emit('update:open', false)"
|
||||||
@@ -415,31 +465,59 @@ function showPublishFailures(result: PublisherPublishResponse): void {
|
|||||||
<section class="publish-modal__section">
|
<section class="publish-modal__section">
|
||||||
<div class="publish-modal__section-header inline-header">
|
<div class="publish-modal__section-header inline-header">
|
||||||
<h3>{{ t("media.publish.coverTitle") }}:</h3>
|
<h3>{{ t("media.publish.coverTitle") }}:</h3>
|
||||||
<a-switch v-model:checked="coverEnabled" />
|
<a-switch
|
||||||
|
:checked="effectiveCoverEnabled"
|
||||||
|
:disabled="coverRequired"
|
||||||
|
@change="handleCoverToggle"
|
||||||
|
/>
|
||||||
</div>
|
</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">
|
<div class="publish-modal__cover-body" v-if="effectiveCoverEnabled">
|
||||||
<a-upload
|
<button
|
||||||
v-model:file-list="fileList"
|
type="button"
|
||||||
list-type="picture-card"
|
class="publish-modal__cover-preview"
|
||||||
class="cover-uploader"
|
@click="coverPickerOpen = true"
|
||||||
:show-upload-list="true"
|
|
||||||
:before-upload="handleBeforeUpload"
|
|
||||||
@remove="handleRemove"
|
|
||||||
accept="image/*"
|
|
||||||
>
|
>
|
||||||
<div v-if="fileList.length < 1">
|
<template v-if="coverAssetUrl">
|
||||||
<div class="upload-icon-circle">
|
<img :src="coverAssetUrl" alt="cover preview" />
|
||||||
<PlusOutlined />
|
</template>
|
||||||
</div>
|
<template v-else>
|
||||||
<div class="upload-text">上传封面图</div>
|
<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>
|
||||||
</a-upload>
|
|
||||||
|
<div v-if="coverAssetUrl" class="publish-modal__cover-file">
|
||||||
|
{{ coverFileName || t("article.editor.coverSaved") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</a-modal>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -649,40 +727,64 @@ function showPublishFailures(result: PublisherPublishResponse): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.publish-modal__cover-body {
|
.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) {
|
.publish-modal__cover-preview {
|
||||||
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%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 0 auto 8px;
|
min-height: 124px;
|
||||||
font-size: 14px;
|
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;
|
font-size: 13px;
|
||||||
color: #595959;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
@@ -693,5 +795,9 @@ function showPublishFailures(result: PublisherPublishResponse): void {
|
|||||||
.publish-modal__grid {
|
.publish-modal__grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.publish-modal__cover-body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -427,8 +427,15 @@ const enUS = {
|
|||||||
platformsTitle: "Publish platforms",
|
platformsTitle: "Publish platforms",
|
||||||
platformsHint: "This stays in sync with Media Management so you can edit the article's publish platforms directly.",
|
platformsHint: "This stays in sync with Media Management so you can edit the article's publish platforms directly.",
|
||||||
coverTitle: "Cover image",
|
coverTitle: "Cover image",
|
||||||
coverHint: "Upload a cover asset before publishing. This is local preview only for now.",
|
coverHint: "Off by default. Once uploaded, the cover is saved with the article.",
|
||||||
|
coverRequired: "Baijiahao is selected, so the cover image becomes required.",
|
||||||
coverUpload: "Upload cover image",
|
coverUpload: "Upload cover image",
|
||||||
|
coverReplace: "Choose again",
|
||||||
|
coverRemove: "Remove cover",
|
||||||
|
coverSaved: "Cover asset saved",
|
||||||
|
coverOff: "Cover image is currently off. Turn it on to upload and crop a cover asset.",
|
||||||
|
coverTagRequired: "Required",
|
||||||
|
coverTagOptional: "Optional",
|
||||||
imagePrompt: "Enter image URL",
|
imagePrompt: "Enter image URL",
|
||||||
tablePicker: {
|
tablePicker: {
|
||||||
empty: "Drag to choose a table size",
|
empty: "Drag to choose a table size",
|
||||||
@@ -566,7 +573,8 @@ const enUS = {
|
|||||||
platformsTitle: "Target platforms",
|
platformsTitle: "Target platforms",
|
||||||
platformsHint: "Previously selected and bound accounts are prefilled first. Unbound or locally unavailable platforms stay disabled.",
|
platformsHint: "Previously selected and bound accounts are prefilled first. Unbound or locally unavailable platforms stay disabled.",
|
||||||
coverTitle: "Cover image",
|
coverTitle: "Cover image",
|
||||||
coverHint: "This version uses a cover asset URL. Upload and crop can be wired later.",
|
coverHint: "Off by default. Uploading opens the cover picker and cropper.",
|
||||||
|
coverUpload: "Upload cover image",
|
||||||
coverPlaceholder: "Enter an optional cover asset URL",
|
coverPlaceholder: "Enter an optional cover asset URL",
|
||||||
messages: {
|
messages: {
|
||||||
success: "{count} publish tasks submitted.",
|
success: "{count} publish tasks submitted.",
|
||||||
@@ -574,6 +582,7 @@ const enUS = {
|
|||||||
partial: "Publish finished with {success} succeeded and {failed} failed.",
|
partial: "Publish finished with {success} succeeded and {failed} failed.",
|
||||||
partialTitle: "Partial publish failure",
|
partialTitle: "Partial publish failure",
|
||||||
selectPlatform: "Choose at least one available platform first.",
|
selectPlatform: "Choose at least one available platform first.",
|
||||||
|
coverRequired: "Baidu Baijiahao is selected. Please upload a cover image first.",
|
||||||
failureTitle: "Publish failed",
|
failureTitle: "Publish failed",
|
||||||
failureItem: "Publishing to [{platform}] failed: [{reason}]",
|
failureItem: "Publishing to [{platform}] failed: [{reason}]",
|
||||||
unknownFailure: "Unknown error",
|
unknownFailure: "Unknown error",
|
||||||
@@ -592,6 +601,46 @@ const enUS = {
|
|||||||
copySuccess: "Link copied.",
|
copySuccess: "Link copied.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
coverPicker: {
|
||||||
|
title: "Select cover image",
|
||||||
|
tabs: {
|
||||||
|
local: "Body / Local Upload",
|
||||||
|
ai: "AI Cover",
|
||||||
|
},
|
||||||
|
previewTitle: "Cover crop preview",
|
||||||
|
previewHintLocked: "Baijiahao is selected, so the cover will be exported as 16:9 when you confirm.",
|
||||||
|
previewHintFree: "There is no forced cover ratio right now. You can upload the original image directly or adjust framing before confirming.",
|
||||||
|
requirementsTitle: "Platform requirements",
|
||||||
|
requirementsHint: "Requirements switch automatically based on the selected publish platforms. Baijiahao is mandatory.",
|
||||||
|
recommendedRatio: "Recommended ratio",
|
||||||
|
recommendedSize: "Recommended size",
|
||||||
|
required: "Required",
|
||||||
|
optional: "Optional",
|
||||||
|
zoom: "Zoom",
|
||||||
|
fileLabel: "Current cover",
|
||||||
|
emptyTitle: "No publish platform selected",
|
||||||
|
emptyPlatforms: "Choose publish platforms first and each platform's cover requirements will appear here.",
|
||||||
|
emptyHint: "Supports PNG, JPG, GIF, and WebP up to 10MB.",
|
||||||
|
aiTitle: "AI cover is coming soon",
|
||||||
|
aiHint: "For now this flow supports local upload and cropping. AI cover generation can be plugged in here later.",
|
||||||
|
actions: {
|
||||||
|
upload: "Local upload",
|
||||||
|
reset: "Reset framing",
|
||||||
|
remove: "Remove cover",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
required: "This platform currently requires a cover image in the publish flow. Publishing is blocked until one is uploaded.",
|
||||||
|
optional: "This platform already supports standalone covers and reuses the current crop when publishing.",
|
||||||
|
unsupported: "This platform's current integration does not consume a standalone cover yet and falls back to body content or the platform default.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
invalidType: "Only PNG, JPG, GIF, and WebP images are supported.",
|
||||||
|
tooLarge: "Image size cannot exceed 10MB.",
|
||||||
|
missingImage: "Select a cover image first.",
|
||||||
|
missingArticle: "The article is not ready yet. Refresh and try again.",
|
||||||
|
imageLoadFailed: "Failed to load the cover image. Please choose another image.",
|
||||||
|
},
|
||||||
|
},
|
||||||
brands: {
|
brands: {
|
||||||
eyebrow: "Brand Library",
|
eyebrow: "Brand Library",
|
||||||
title: "Brand Library",
|
title: "Brand Library",
|
||||||
|
|||||||
@@ -434,8 +434,15 @@ const zhCN = {
|
|||||||
platformsTitle: "发布平台",
|
platformsTitle: "发布平台",
|
||||||
platformsHint: "这里会同步媒体管理中的平台和绑定状态,支持直接编辑文章的发布平台。",
|
platformsHint: "这里会同步媒体管理中的平台和绑定状态,支持直接编辑文章的发布平台。",
|
||||||
coverTitle: "封面图",
|
coverTitle: "封面图",
|
||||||
coverHint: "发布前可先上传封面素材,当前只做本地预览。",
|
coverHint: "默认关闭。上传后会保存为文章封面素材。",
|
||||||
|
coverRequired: "已选择百家号,封面图为必传项。",
|
||||||
coverUpload: "上传封面图",
|
coverUpload: "上传封面图",
|
||||||
|
coverReplace: "重新选择",
|
||||||
|
coverRemove: "移除封面",
|
||||||
|
coverSaved: "已保存封面素材",
|
||||||
|
coverOff: "封面图当前已关闭,开启后可上传并裁剪封面素材。",
|
||||||
|
coverTagRequired: "必传",
|
||||||
|
coverTagOptional: "可选",
|
||||||
imagePrompt: "请输入图片地址",
|
imagePrompt: "请输入图片地址",
|
||||||
tablePicker: {
|
tablePicker: {
|
||||||
empty: "拖拽选择表格尺寸",
|
empty: "拖拽选择表格尺寸",
|
||||||
@@ -573,7 +580,8 @@ const zhCN = {
|
|||||||
platformsTitle: "发布平台",
|
platformsTitle: "发布平台",
|
||||||
platformsHint: "会优先回填文章里已选择且已绑定的账号,未绑定或本地登录态异常的平台不可选。",
|
platformsHint: "会优先回填文章里已选择且已绑定的账号,未绑定或本地登录态异常的平台不可选。",
|
||||||
coverTitle: "封面图",
|
coverTitle: "封面图",
|
||||||
coverHint: "当前版本先使用封面素材 URL,后续可以接入上传与裁剪。",
|
coverHint: "默认关闭。点击上传后可选择或裁剪封面。",
|
||||||
|
coverUpload: "上传封面图",
|
||||||
coverPlaceholder: "请输入封面素材 URL(可选)",
|
coverPlaceholder: "请输入封面素材 URL(可选)",
|
||||||
messages: {
|
messages: {
|
||||||
success: "已提交 {count} 个平台发布任务",
|
success: "已提交 {count} 个平台发布任务",
|
||||||
@@ -581,6 +589,7 @@ const zhCN = {
|
|||||||
partial: "发布执行完成,成功 {success} 个,失败 {failed} 个",
|
partial: "发布执行完成,成功 {success} 个,失败 {failed} 个",
|
||||||
partialTitle: "部分发布失败",
|
partialTitle: "部分发布失败",
|
||||||
selectPlatform: "请先选择至少一个可发布的平台",
|
selectPlatform: "请先选择至少一个可发布的平台",
|
||||||
|
coverRequired: "已选择百家号,请先上传封面图",
|
||||||
failureTitle: "发布失败",
|
failureTitle: "发布失败",
|
||||||
failureItem: "文章发布到平台【{platform}】失败【{reason}】",
|
failureItem: "文章发布到平台【{platform}】失败【{reason}】",
|
||||||
unknownFailure: "未知错误",
|
unknownFailure: "未知错误",
|
||||||
@@ -599,6 +608,46 @@ const zhCN = {
|
|||||||
copySuccess: "链接已复制",
|
copySuccess: "链接已复制",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
coverPicker: {
|
||||||
|
title: "选择封面图",
|
||||||
|
tabs: {
|
||||||
|
local: "正文/本地上传",
|
||||||
|
ai: "AI封图",
|
||||||
|
},
|
||||||
|
previewTitle: "封面裁剪预览",
|
||||||
|
previewHintLocked: "已选择百家号,确认时会按 16:9 输出封面。",
|
||||||
|
previewHintFree: "当前不限制封面比例,可直接上传原图;如需调整也可拖拽缩放后再确认。",
|
||||||
|
requirementsTitle: "平台封面要求",
|
||||||
|
requirementsHint: "会根据当前选中的发布平台自动切换要求,百家号为强制上传。",
|
||||||
|
recommendedRatio: "推荐比例",
|
||||||
|
recommendedSize: "推荐尺寸",
|
||||||
|
required: "必传",
|
||||||
|
optional: "可选",
|
||||||
|
zoom: "缩放",
|
||||||
|
fileLabel: "当前封面",
|
||||||
|
emptyTitle: "暂未选择发布平台",
|
||||||
|
emptyPlatforms: "选择发布平台后,这里会自动显示每个平台的封面要求。",
|
||||||
|
emptyHint: "支持 PNG、JPG、GIF、WebP,最大 10MB",
|
||||||
|
aiTitle: "AI 封图即将开放",
|
||||||
|
aiHint: "当前先支持本地上传和裁剪,后续会把 AI 生成封面接进这里。",
|
||||||
|
actions: {
|
||||||
|
upload: "本地上传",
|
||||||
|
reset: "重置取景",
|
||||||
|
remove: "移除封面",
|
||||||
|
},
|
||||||
|
notes: {
|
||||||
|
required: "该平台当前发布链路要求必须携带封面图,未上传时不能提交发布。",
|
||||||
|
optional: "该平台当前接入已支持独立封面,会复用当前裁剪结果完成发布。",
|
||||||
|
unsupported: "该平台当前接入暂不消费独立封面,会沿用正文内容或平台默认样式。",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
invalidType: "仅支持 PNG、JPG、GIF、WebP 图片",
|
||||||
|
tooLarge: "图片不能超过 10MB",
|
||||||
|
missingImage: "请先选择一张封面图",
|
||||||
|
missingArticle: "文章信息还没准备好,请刷新后重试",
|
||||||
|
imageLoadFailed: "封面图加载失败,请重新选择图片",
|
||||||
|
},
|
||||||
|
},
|
||||||
brands: {
|
brands: {
|
||||||
eyebrow: "Brand Library",
|
eyebrow: "Brand Library",
|
||||||
title: "品牌词库",
|
title: "品牌词库",
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ export function getApiBaseURL(): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveApiURL(value?: string | null): string {
|
||||||
|
const trimmed = String(value ?? "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
if (/^\/\//.test(trimmed)) {
|
||||||
|
return `https:${trimmed}`;
|
||||||
|
}
|
||||||
|
const apiBaseURL = getApiBaseURL();
|
||||||
|
if (!apiBaseURL) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return new URL(trimmed, apiBaseURL.endsWith("/") ? apiBaseURL : `${apiBaseURL}/`).toString();
|
||||||
|
}
|
||||||
|
|
||||||
export const apiClient = createApiClient({
|
export const apiClient = createApiClient({
|
||||||
baseURL,
|
baseURL,
|
||||||
auth: {
|
auth: {
|
||||||
@@ -229,7 +247,10 @@ export const articlesApi = {
|
|||||||
formData,
|
formData,
|
||||||
);
|
);
|
||||||
|
|
||||||
return response.data.data;
|
return {
|
||||||
|
...response.data.data,
|
||||||
|
url: resolveApiURL(response.data.data.url),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
versions(id: number) {
|
versions(id: number) {
|
||||||
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
|
return apiClient.get<ArticleVersion[]>(`/api/tenant/articles/${id}/versions`);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { normalizePublishPlatformIds } from "./publish-platforms";
|
||||||
|
|
||||||
|
export type CoverSupportMode = "native" | "unsupported";
|
||||||
|
|
||||||
|
export interface PlatformCoverRequirement {
|
||||||
|
platformId: string;
|
||||||
|
required: boolean;
|
||||||
|
supportMode: CoverSupportMode;
|
||||||
|
aspectRatio: number | null;
|
||||||
|
aspectLabel: string | null;
|
||||||
|
outputWidth: number | null;
|
||||||
|
outputHeight: number | null;
|
||||||
|
priority: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRequirement = {
|
||||||
|
required: false,
|
||||||
|
supportMode: "unsupported" as CoverSupportMode,
|
||||||
|
aspectRatio: null,
|
||||||
|
aspectLabel: null,
|
||||||
|
outputWidth: null,
|
||||||
|
outputHeight: null,
|
||||||
|
priority: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const requirementCatalog: Record<string, Partial<PlatformCoverRequirement>> = {
|
||||||
|
baijiahao: {
|
||||||
|
required: true,
|
||||||
|
supportMode: "native",
|
||||||
|
aspectRatio: 16 / 9,
|
||||||
|
aspectLabel: "16:9",
|
||||||
|
outputWidth: 1200,
|
||||||
|
outputHeight: 675,
|
||||||
|
priority: 100,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPlatformCoverRequirements(platformIds?: Array<string | null | undefined>): PlatformCoverRequirement[] {
|
||||||
|
return normalizePublishPlatformIds(platformIds).map((platformId) => {
|
||||||
|
const config = requirementCatalog[platformId] ?? {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
platformId,
|
||||||
|
required: config.required ?? defaultRequirement.required,
|
||||||
|
supportMode: config.supportMode ?? defaultRequirement.supportMode,
|
||||||
|
aspectRatio: config.aspectRatio ?? defaultRequirement.aspectRatio,
|
||||||
|
aspectLabel: config.aspectLabel ?? defaultRequirement.aspectLabel,
|
||||||
|
outputWidth: config.outputWidth ?? defaultRequirement.outputWidth,
|
||||||
|
outputHeight: config.outputHeight ?? defaultRequirement.outputHeight,
|
||||||
|
priority: config.priority ?? defaultRequirement.priority,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function coverUploadRequired(platformIds?: Array<string | null | undefined>): boolean {
|
||||||
|
return getPlatformCoverRequirements(platformIds).some((item) => item.required);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPrimaryCoverRequirement(platformIds?: Array<string | null | undefined>): PlatformCoverRequirement {
|
||||||
|
const requirements = getPlatformCoverRequirements(platformIds);
|
||||||
|
if (!requirements.length) {
|
||||||
|
return {
|
||||||
|
platformId: "default",
|
||||||
|
...defaultRequirement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...requirements].sort((left, right) => right.priority - left.priority)[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveCoverFileName(value?: string | null): string {
|
||||||
|
const trimmed = String(value ?? "").trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pathname = new URL(trimmed, "https://cover.local").pathname;
|
||||||
|
const lastSegment = pathname.split("/").filter(Boolean).at(-1) ?? "";
|
||||||
|
return decodeURIComponent(lastSegment);
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ const errorMessageMap: Record<string, string> = {
|
|||||||
object_storage_unavailable: "对象存储未配置或当前不可用",
|
object_storage_unavailable: "对象存储未配置或当前不可用",
|
||||||
invalid_payload: "请求参数不合法",
|
invalid_payload: "请求参数不合法",
|
||||||
update_failed: "保存文章失败",
|
update_failed: "保存文章失败",
|
||||||
|
publish_cover_required: "已选择百家号,请先上传封面图",
|
||||||
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
|
publisher_plugin_timeout: "浏览器插件响应超时,请刷新当前页面后重试",
|
||||||
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
|
publisher_plugin_empty_response: "浏览器插件未返回数据,请刷新当前页面后重试",
|
||||||
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
|
publisher_plugin_invalid_response: "浏览器插件返回了无效数据,请刷新当前页面后重试",
|
||||||
|
|||||||
@@ -2,15 +2,20 @@
|
|||||||
import { LeftOutlined } from "@ant-design/icons-vue";
|
import { LeftOutlined } from "@ant-design/icons-vue";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
|
||||||
import { message } from "ant-design-vue";
|
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 { useI18n } from "vue-i18n";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { MilkdownProvider } from "@milkdown/vue";
|
import { MilkdownProvider } from "@milkdown/vue";
|
||||||
|
|
||||||
import ArticleEditorCanvas from "@/components/ArticleEditorCanvas.vue";
|
import ArticleEditorCanvas from "@/components/ArticleEditorCanvas.vue";
|
||||||
|
import CoverPickerModal from "@/components/CoverPickerModal.vue";
|
||||||
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
import PublishArticleModal from "@/components/PublishArticleModal.vue";
|
||||||
import PublishPlatformSelector from "@/components/PublishPlatformSelector.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 { formatError } from "@/lib/errors";
|
||||||
import {
|
import {
|
||||||
buildPublishPlatformOptions,
|
buildPublishPlatformOptions,
|
||||||
@@ -29,8 +34,11 @@ const initialTitle = ref("");
|
|||||||
const initialMarkdown = ref("");
|
const initialMarkdown = ref("");
|
||||||
const publishPlatforms = ref<string[]>([]);
|
const publishPlatforms = ref<string[]>([]);
|
||||||
const initialPublishPlatforms = ref<string[]>([]);
|
const initialPublishPlatforms = ref<string[]>([]);
|
||||||
const coverEnabled = ref(true);
|
const coverEnabled = ref(false);
|
||||||
const coverPreviewUrl = ref("");
|
const coverAssetUrl = ref("");
|
||||||
|
const coverFileName = ref("");
|
||||||
|
const initialCoverAssetUrl = ref("");
|
||||||
|
const coverPickerOpen = ref(false);
|
||||||
const leaveModalOpen = ref(false);
|
const leaveModalOpen = ref(false);
|
||||||
const publishModalOpen = ref(false);
|
const publishModalOpen = ref(false);
|
||||||
|
|
||||||
@@ -74,17 +82,39 @@ watch(
|
|||||||
initialMarkdown.value = nextMarkdown;
|
initialMarkdown.value = nextMarkdown;
|
||||||
publishPlatforms.value = normalizePublishPlatformIds(detail.platforms ?? []);
|
publishPlatforms.value = normalizePublishPlatformIds(detail.platforms ?? []);
|
||||||
initialPublishPlatforms.value = [...publishPlatforms.value];
|
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 },
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
const detail = computed(() => detailQuery.data.value);
|
const detail = computed(() => detailQuery.data.value);
|
||||||
const editorLocked = computed(() => detail.value?.generate_status !== "completed");
|
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(
|
const hasChanges = computed(
|
||||||
() =>
|
() =>
|
||||||
title.value.trim() !== initialTitle.value.trim() ||
|
title.value.trim() !== initialTitle.value.trim() ||
|
||||||
markdown.value !== initialMarkdown.value ||
|
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({
|
const saveMutation = useMutation({
|
||||||
@@ -93,6 +123,7 @@ const saveMutation = useMutation({
|
|||||||
title: title.value.trim(),
|
title: title.value.trim(),
|
||||||
markdown_content: markdown.value,
|
markdown_content: markdown.value,
|
||||||
platforms: normalizePublishPlatformIds(publishPlatforms.value),
|
platforms: normalizePublishPlatformIds(publishPlatforms.value),
|
||||||
|
cover_asset_url: normalizedCoverValue.value,
|
||||||
}),
|
}),
|
||||||
onSuccess: async (data) => {
|
onSuccess: async (data) => {
|
||||||
message.success(t("article.editor.messages.saved"));
|
message.success(t("article.editor.messages.saved"));
|
||||||
@@ -102,6 +133,10 @@ const saveMutation = useMutation({
|
|||||||
initialMarkdown.value = markdown.value;
|
initialMarkdown.value = markdown.value;
|
||||||
publishPlatforms.value = normalizePublishPlatformIds(data.platforms ?? []);
|
publishPlatforms.value = normalizePublishPlatformIds(data.platforms ?? []);
|
||||||
initialPublishPlatforms.value = [...publishPlatforms.value];
|
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([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
queryClient.invalidateQueries({ queryKey: ["articles"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
queryClient.invalidateQueries({ queryKey: ["workspace"] }),
|
||||||
@@ -163,6 +198,17 @@ async function uploadEditorImage(file: File): Promise<string> {
|
|||||||
return result.url;
|
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> {
|
async function handlePublished(): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
detailQuery.refetch(),
|
detailQuery.refetch(),
|
||||||
@@ -248,26 +294,6 @@ async function handleSaveAndLeave(): Promise<void> {
|
|||||||
navigateBack();
|
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 {
|
function stripLeadingTitleHeading(titleValue: string, markdownValue: string): string {
|
||||||
const normalizedTitle = titleValue.trim();
|
const normalizedTitle = titleValue.trim();
|
||||||
if (!normalizedTitle) {
|
if (!normalizedTitle) {
|
||||||
@@ -361,30 +387,51 @@ function serializePlatformSelection(platformIds: string[]): string {
|
|||||||
<div class="article-editor-view__card-head">
|
<div class="article-editor-view__card-head">
|
||||||
<div>
|
<div>
|
||||||
<h3>{{ t("article.editor.coverTitle") }}</h3>
|
<h3>{{ t("article.editor.coverTitle") }}</h3>
|
||||||
<p>{{ t("article.editor.coverHint") }}</p>
|
<p>{{ coverRequired ? t("article.editor.coverRequired") : t("article.editor.coverHint") }}</p>
|
||||||
</div>
|
</div>
|
||||||
<a-switch v-model:checked="coverEnabled" />
|
<a-switch
|
||||||
|
v-model:checked="coverEnabled"
|
||||||
|
:disabled="coverRequired"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label
|
<div
|
||||||
class="article-editor-view__cover-dropzone"
|
v-if="coverEnabled || coverRequired"
|
||||||
:class="{ 'article-editor-view__cover-dropzone--disabled': !coverEnabled }"
|
class="article-editor-view__cover-panel"
|
||||||
>
|
>
|
||||||
<input
|
<button
|
||||||
type="file"
|
type="button"
|
||||||
accept="image/*"
|
class="article-editor-view__cover-dropzone"
|
||||||
:disabled="!coverEnabled"
|
@click="coverPickerOpen = true"
|
||||||
hidden
|
>
|
||||||
@change="handleCoverChange"
|
<template v-if="coverAssetUrl">
|
||||||
/>
|
<img :src="coverAssetUrl" alt="cover preview" />
|
||||||
<template v-if="coverPreviewUrl">
|
</template>
|
||||||
<img :src="coverPreviewUrl" alt="cover preview" />
|
<template v-else>
|
||||||
</template>
|
<span class="article-editor-view__cover-plus">+</span>
|
||||||
<template v-else>
|
<span>{{ t("article.editor.coverUpload") }}</span>
|
||||||
<span class="article-editor-view__cover-plus">+</span>
|
</template>
|
||||||
<span>{{ t("article.editor.coverUpload") }}</span>
|
</button>
|
||||||
</template>
|
|
||||||
</label>
|
<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>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
@@ -426,6 +473,15 @@ function serializePlatformSelection(platformIds: string[]): string {
|
|||||||
:article-id="detail?.id ?? null"
|
:article-id="detail?.id ?? null"
|
||||||
@published="handlePublished"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -546,14 +602,21 @@ function serializePlatformSelection(platformIds: string[]): string {
|
|||||||
gap: 12px;
|
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 {
|
.article-editor-view__cover-dropzone {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
min-height: 176px;
|
min-height: 116px;
|
||||||
margin-top: 18px;
|
padding: 0;
|
||||||
border: 1px dashed #d3dceb;
|
border: 1px dashed #d3dceb;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
background:
|
background:
|
||||||
@@ -566,13 +629,27 @@ function serializePlatformSelection(platformIds: string[]): string {
|
|||||||
|
|
||||||
.article-editor-view__cover-dropzone img {
|
.article-editor-view__cover-dropzone img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 176px;
|
height: 116px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-editor-view__cover-dropzone--disabled {
|
.article-editor-view__cover-info {
|
||||||
opacity: 0.48;
|
display: flex;
|
||||||
cursor: not-allowed;
|
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 {
|
.article-editor-view__cover-plus {
|
||||||
@@ -596,6 +673,10 @@ function serializePlatformSelection(platformIds: string[]): string {
|
|||||||
.article-editor-view__rail {
|
.article-editor-view__rail {
|
||||||
order: -1;
|
order: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-editor-view__cover-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export interface ArticleDetail {
|
|||||||
template_name: string | null;
|
template_name: string | null;
|
||||||
generation_mode: string | null;
|
generation_mode: string | null;
|
||||||
platforms: string[];
|
platforms: string[];
|
||||||
|
cover_asset_url: string | null;
|
||||||
generate_status: string;
|
generate_status: string;
|
||||||
publish_status: string;
|
publish_status: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -275,6 +276,7 @@ export interface UpdateArticleRequest {
|
|||||||
title: string;
|
title: string;
|
||||||
markdown_content: string;
|
markdown_content: string;
|
||||||
platforms?: string[];
|
platforms?: string[];
|
||||||
|
cover_asset_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ArticleImageUploadResponse {
|
export interface ArticleImageUploadResponse {
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ type ArticleDetailResponse struct {
|
|||||||
TemplateName *string `json:"template_name"`
|
TemplateName *string `json:"template_name"`
|
||||||
GenerationMode *string `json:"generation_mode"`
|
GenerationMode *string `json:"generation_mode"`
|
||||||
Platforms []string `json:"platforms"`
|
Platforms []string `json:"platforms"`
|
||||||
|
CoverAssetURL *string `json:"cover_asset_url"`
|
||||||
GenerateStatus string `json:"generate_status"`
|
GenerateStatus string `json:"generate_status"`
|
||||||
PublishStatus string `json:"publish_status"`
|
PublishStatus string `json:"publish_status"`
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
@@ -310,6 +311,7 @@ func (s *ArticleService) Detail(ctx context.Context, id int64) (*ArticleDetailRe
|
|||||||
}
|
}
|
||||||
d.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
d.GenerationMode = resolveArticleGenerationMode(dbSourceType, inputParamsJSON)
|
||||||
d.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
d.Platforms = resolveArticlePlatforms(wizardStateJSON, inputParamsJSON)
|
||||||
|
d.CoverAssetURL = resolveArticleCoverAssetURL(wizardStateJSON)
|
||||||
d.WordCount = resolveWordCount(d.MarkdownContent, d.WordCount)
|
d.WordCount = resolveWordCount(d.MarkdownContent, d.WordCount)
|
||||||
return &d, nil
|
return &d, nil
|
||||||
}
|
}
|
||||||
@@ -366,6 +368,7 @@ type UpdateArticleRequest struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
MarkdownContent string `json:"markdown_content"`
|
MarkdownContent string `json:"markdown_content"`
|
||||||
Platforms []string `json:"platforms"`
|
Platforms []string `json:"platforms"`
|
||||||
|
CoverAssetURL *string `json:"cover_asset_url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArticleImageUploadResponse struct {
|
type ArticleImageUploadResponse struct {
|
||||||
@@ -418,7 +421,7 @@ func (s *ArticleService) Update(ctx context.Context, id int64, req UpdateArticle
|
|||||||
|
|
||||||
wordCount := countArticleWords(markdown)
|
wordCount := countArticleWords(markdown)
|
||||||
sourceLabel := "手动编辑"
|
sourceLabel := "手动编辑"
|
||||||
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms)
|
nextWizardStateJSON, err := mergeArticleWizardState(wizardStateJSON, title, req.Platforms, req.CoverAssetURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
|
return nil, response.ErrInternal(50012, "update_failed", "failed to update article metadata")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -456,6 +456,9 @@ func (s *MediaService) CreatePublishBatch(ctx context.Context, articleID int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if publishBatchRequiresCover(accountSeeds) && strings.TrimSpace(pointerStringValue(req.CoverAssetURL)) == "" {
|
||||||
|
return nil, response.ErrBadRequest(40044, "publish_cover_required", "baijiahao publishing requires a cover image")
|
||||||
|
}
|
||||||
|
|
||||||
var publishBatchID int64
|
var publishBatchID int64
|
||||||
if err := tx.QueryRow(ctx, `
|
if err := tx.QueryRow(ctx, `
|
||||||
@@ -529,6 +532,22 @@ func (s *MediaService) CreatePublishBatch(ctx context.Context, articleID int64,
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func publishBatchRequiresCover(accounts []platformAccountSeed) bool {
|
||||||
|
for _, account := range accounts {
|
||||||
|
if strings.TrimSpace(account.PlatformID) == "baijiahao" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func pointerStringValue(value *string) string {
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *value
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID int64) ([]PublishRecordResponse, error) {
|
func (s *MediaService) ListArticlePublishRecords(ctx context.Context, articleID int64) ([]PublishRecordResponse, error) {
|
||||||
actor := auth.MustActor(ctx)
|
actor := auth.MustActor(ctx)
|
||||||
return s.listArticlePublishRecords(ctx, actor.TenantID, articleID)
|
return s.listArticlePublishRecords(ctx, actor.TenantID, articleID)
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ func (s *PromptRuleGenerationService) GenerateFromRule(ctx context.Context, req
|
|||||||
}
|
}
|
||||||
|
|
||||||
inputJSON, _ := json.Marshal(inputParams)
|
inputJSON, _ := json.Marshal(inputParams)
|
||||||
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs)
|
wizardStateJSON, _ := mergeArticleWizardState(nil, promptRuleGeneratingTitlePlaceholder, platformIDs, nil)
|
||||||
|
|
||||||
tx, err := s.pool.Begin(ctx)
|
tx, err := s.pool.Begin(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -78,6 +78,31 @@ func extractPlatformsFromJSONPayload(raw []byte) []string {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveArticleCoverAssetURL(wizardStateJSON []byte) *string {
|
||||||
|
value := strings.TrimSpace(extractStringFromJSONPayload(wizardStateJSON, "cover_asset_url"))
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractStringFromJSONPayload(raw []byte, key string) string {
|
||||||
|
if len(raw) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := payload[key].(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
|
||||||
func normalizePlatformsFromValue(raw interface{}) []string {
|
func normalizePlatformsFromValue(raw interface{}) []string {
|
||||||
switch typed := raw.(type) {
|
switch typed := raw.(type) {
|
||||||
case nil:
|
case nil:
|
||||||
@@ -101,7 +126,7 @@ func normalizePlatformsFromValue(raw interface{}) []string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeArticleWizardState(raw []byte, title string, platforms []string) ([]byte, error) {
|
func mergeArticleWizardState(raw []byte, title string, platforms []string, coverAssetURL *string) ([]byte, error) {
|
||||||
state := make(map[string]interface{})
|
state := make(map[string]interface{})
|
||||||
if len(raw) > 0 {
|
if len(raw) > 0 {
|
||||||
_ = json.Unmarshal(raw, &state)
|
_ = json.Unmarshal(raw, &state)
|
||||||
@@ -123,5 +148,14 @@ func mergeArticleWizardState(raw []byte, title string, platforms []string) ([]by
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if coverAssetURL != nil {
|
||||||
|
trimmedCoverURL := strings.TrimSpace(*coverAssetURL)
|
||||||
|
if trimmedCoverURL == "" {
|
||||||
|
delete(state, "cover_asset_url")
|
||||||
|
} else {
|
||||||
|
state["cover_asset_url"] = trimmedCoverURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return json.Marshal(state)
|
return json.Marshal(state)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user