Files
geo/apps/admin-web/src/components/CoverPickerModal.vue
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:39:09 +08:00

889 lines
22 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 ImageLibraryPicker from '@/components/ImageLibraryPicker.vue'
import { articlesApi } from '@/lib/api'
import {
deriveCoverFileName,
getPrimaryCoverRequirement,
type PlatformCoverRequirement,
} from '@/lib/cover-requirements'
import { formatError } from '@/lib/errors'
import {
IMAGE_UPLOAD_ACCEPT,
useImageUploadProgress,
validateImageUploadFile,
} from '@/lib/image-webp'
import type { ImageAssetItem } from '@geo/shared-types'
type PickerUploadResult = {
url: string
fileName?: string | null
assetId?: number | null
}
const props = defineProps<{
open: boolean
articleId: number | null
platformIds: string[]
currentUrl?: string | null
currentFileName?: string | null
currentAssetId?: number | null
title?: string
localTabLabel?: string
emptyUploadTitle?: string
emptyUploadHint?: string
uploadHandler?: (file: File) => Promise<PickerUploadResult>
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
confirmed: [payload: { url: string; fileName: string; assetId?: number | null }]
}>()
const { t } = useI18n()
const imageUploadProgress = useImageUploadProgress()
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 selectedLibraryImageId = ref<number | 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 modalTitle = computed(() => props.title?.trim() || t('coverPicker.title'))
const resolvedLocalTabLabel = computed(
() => props.localTabLabel?.trim() || t('coverPicker.tabs.local'),
)
const resolvedEmptyUploadTitle = computed(
() => props.emptyUploadTitle?.trim() || t('coverPicker.dropzoneTitle'),
)
const resolvedEmptyUploadHint = computed(
() => props.emptyUploadHint?.trim() || t('coverPicker.dropzoneHint'),
)
const localTabLabelDisplay = computed(() =>
workingUrl.value ? `${resolvedLocalTabLabel.value} (1)` : resolvedLocalTabLabel.value,
)
const hasImage = computed(() => Boolean(workingUrl.value))
const imageUploadBusy = computed(() => imageUploadProgress.visible)
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
selectedLibraryImageId.value = props.currentAssetId ?? 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 {
if (imageUploadBusy.value) {
return
}
fileInput.value?.click()
}
function handleFileChange(event: Event): void {
if (imageUploadBusy.value) {
;(event.target as HTMLInputElement).value = ''
return
}
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
try {
validateImageUploadFile(file)
} catch (error) {
message.warning(formatError(error))
return
}
revokeLocalObjectUrl()
const objectUrl = URL.createObjectURL(file)
localObjectUrl.value = objectUrl
workingUrl.value = objectUrl
workingFileName.value = file.name
sourceKind.value = 'local'
localFile.value = file
selectedLibraryImageId.value = null
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
selectedLibraryImageId.value = null
transformDirty.value = false
resetStage()
revokeLocalObjectUrl()
}
function handleLibrarySelect(image: ImageAssetItem): void {
activeTab.value = 'local'
workingUrl.value = image.url
workingFileName.value = image.name
sourceKind.value = 'remote'
selectedLibraryImageId.value = image.id
localFile.value = null
transformDirty.value = false
void loadImageMeta(image.url)
}
async function handleConfirm(): Promise<void> {
if (!workingUrl.value) {
message.warning(t('coverPicker.messages.missingImage'))
return
}
if (!props.uploadHandler && !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',
assetId: selectedLibraryImageId.value,
})
emit('update:open', false)
return
}
confirmLoading.value = true
try {
if (
sourceKind.value === 'local' &&
!transformDirty.value &&
!aspectRatioLocked.value &&
localFile.value
) {
const fallbackFileName =
localFile.value.name || deriveCoverFileName(workingUrl.value) || 'cover.webp'
const uploaded = await uploadPickedFile(localFile.value, fallbackFileName)
emit('confirmed', {
url: uploaded.url,
fileName: uploaded.fileName,
assetId: uploaded.assetId,
})
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 uploadPickedFile(file, fileName)
emit('confirmed', {
url: uploaded.url,
fileName: uploaded.fileName,
assetId: uploaded.assetId,
})
emit('update:open', false)
} catch (error) {
message.error(formatError(error))
} finally {
confirmLoading.value = false
}
}
async function uploadPickedFile(
file: File,
fallbackFileName: string,
): Promise<{ url: string; fileName: string; assetId?: number | null }> {
if (props.uploadHandler) {
const uploaded = await props.uploadHandler(file)
return {
url: uploaded.url,
fileName: String(uploaded.fileName ?? '').trim() || fallbackFileName,
assetId: uploaded.assetId ?? null,
}
}
if (!props.articleId) {
throw new Error(t('coverPicker.messages.missingArticle'))
}
const uploaded = await articlesApi.uploadImage(props.articleId, file)
return {
url: uploaded.url,
fileName: fallbackFileName,
assetId: null,
}
}
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="modalTitle"
@ok="handleConfirm"
@cancel="closeModal"
>
<div class="cover-picker">
<input
ref="fileInput"
type="file"
:accept="IMAGE_UPLOAD_ACCEPT"
:disabled="imageUploadBusy"
hidden
@change="handleFileChange"
/>
<a-tabs v-model:activeKey="activeTab" class="cover-picker__tabs">
<a-tab-pane key="local" :tab="localTabLabelDisplay">
<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"
:disabled="imageUploadBusy"
@click="openFileDialog"
>
<span class="cover-picker__stage-empty-icon">
<CloudUploadOutlined />
</span>
<strong>{{ resolvedEmptyUploadTitle }}</strong>
<small>{{ resolvedEmptyUploadHint }}</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" style="margin-left: 16px" @click="resetTransform">
{{ t('coverPicker.actions.reset') }}
</a-button>
</div>
<div v-if="hasImage" class="cover-picker__thumbs">
<button
class="cover-picker__upload-tile"
type="button"
:disabled="imageUploadBusy"
@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="library" :tab="t('images.libraryTab')">
<div class="cover-picker__library">
<ImageLibraryPicker @select="handleLibrarySelect" />
</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__library {
border: none;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.cover-picker__workspace {
padding: 0;
}
.cover-picker__library {
padding: 0;
height: 520px;
}
.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__stage-empty:disabled,
.cover-picker__upload-tile:disabled {
opacity: 0.56;
cursor: not-allowed;
}
.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);
}
@media (max-width: 960px) {
.cover-picker__thumbs {
flex-wrap: wrap;
}
.cover-picker__remove {
margin-left: 0;
}
}
</style>