9f721f6088
- 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.
783 lines
19 KiB
Vue
783 lines
19 KiB
Vue
<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>
|