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

783 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { 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>支持jpgpng等格式单张图片最大支持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>