Files
geo/apps/admin-web/src/components/CoverPickerModal.vue
T

783 lines
19 KiB
Vue
Raw Normal View History

<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>