This commit is contained in:
parent
c29caf01c8
commit
9d80345b77
7 changed files with 332 additions and 15 deletions
78
src/api/merchImages.js
Normal file
78
src/api/merchImages.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { apiClient } from '@/services/apiClient.js'
|
||||
|
||||
export const useMerchImagesApi = () => {
|
||||
const uploadImage = async (uuid, file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('imageType', 'all')
|
||||
|
||||
try {
|
||||
const response = await apiClient.post(`/merch/images/${uuid}`, formData)
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Upload failed: ${response.status}`)
|
||||
}
|
||||
|
||||
return response.data
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const cachedImages = new Map() // Map<uuid, { etag, url }>
|
||||
const getImageUrl = async (uuid, type) => {
|
||||
try {
|
||||
const response = await apiClient.get(`/merch/images/${uuid}`, { type })
|
||||
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Get image failed: ${response.status}`)
|
||||
}
|
||||
|
||||
const { link, ETag } = response.data
|
||||
|
||||
if (cachedImages.has(uuid) && cachedImages.get(uuid).etag === ETag) {
|
||||
return cachedImages.get(uuid)
|
||||
}
|
||||
|
||||
const res = await fetch(link)
|
||||
if (!res.ok) throw new Error(`Failed to load image: ${res.status}`)
|
||||
|
||||
const blob = await res.blob()
|
||||
const imgUrl = URL.createObjectURL(blob)
|
||||
|
||||
cachedImages.set(uuid, { imgUrl, etag: ETag })
|
||||
return { imgUrl, etag: ETag }
|
||||
|
||||
} catch (error) {
|
||||
console.error('Get image failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const deleteImage = async (uuid) => {
|
||||
try {
|
||||
const response = await apiClient.delete(`/merch/images/${uuid}`)
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Delete failed: ${response.status}`)
|
||||
}
|
||||
|
||||
if (cachedImages.has(uuid)) {
|
||||
const cached = cachedImages.get(uuid)
|
||||
if (cached.imgUrl?.startsWith('blob:')) URL.revokeObjectURL(cached.imgUrl)
|
||||
cachedImages.delete(uuid)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Delete image failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uploadImage,
|
||||
getImageUrl,
|
||||
deleteImage,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<svg
|
||||
height="800px"
|
||||
width="800px"
|
||||
version="1.1"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 512 512"
|
||||
xml:space="preserve"
|
||||
style="width: 100%; height: 100%; display: block;"
|
||||
>
|
||||
<polygon
|
||||
style="fill: #e0b76e"
|
||||
|
|
|
|||
|
|
@ -126,10 +126,17 @@ export const apiClient = {
|
|||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||
return request(fullUrl, { method: 'GET' });
|
||||
},
|
||||
post: (url, data) => request(url, {
|
||||
post: (url, data) => {
|
||||
const isFormData = data instanceof FormData
|
||||
|
||||
return request(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
body: isFormData ? data : JSON.stringify(data),
|
||||
headers: isFormData
|
||||
? {}
|
||||
: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
},
|
||||
put: (url, data) => request(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
export function convertIso(isoString) {
|
||||
const date = new Date(isoString);
|
||||
|
||||
// Извлекаем компоненты времени и даты
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // Месяцы в JS — от 0 до 11
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const year = date.getUTCFullYear();
|
||||
|
||||
return `${hours}:${minutes}:${seconds} ${day}-${month}-${year}`;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,36 @@
|
|||
<script setup>
|
||||
import BoxIcon from '@/components/icons/BoxIcon.vue'
|
||||
import { useMerchImagesApi } from '@/api/merchImages.js'
|
||||
import { onMounted, ref } from 'vue'
|
||||
const { getImageUrl } = useMerchImagesApi()
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
merch: {
|
||||
type: Object,
|
||||
required: true,
|
||||
}
|
||||
})
|
||||
|
||||
const fileList = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { imgUrl } = await getImageUrl(props.merch.merch_uuid, 'thumbnail')
|
||||
fileList.value = [
|
||||
{
|
||||
name: 'full.jpg',
|
||||
url: imgUrl,
|
||||
status: 'finished',
|
||||
},
|
||||
]
|
||||
} catch (error) {
|
||||
fileList.value = []
|
||||
if (!error.message?.includes('404')) {
|
||||
console.error('Error getting image: ', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -16,7 +40,13 @@ defineProps({
|
|||
</template>
|
||||
<template #cover>
|
||||
<div class="cover-wrapper">
|
||||
<BoxIcon />
|
||||
<img
|
||||
v-if="fileList.length > 0 && fileList[0].url"
|
||||
:src="fileList[0].url"
|
||||
alt="Thumbnail"
|
||||
class="thumbnail"
|
||||
/>
|
||||
<BoxIcon v-else />
|
||||
</div>
|
||||
</template>
|
||||
</n-card>
|
||||
|
|
@ -52,6 +82,13 @@ defineProps({
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-width: 80%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.cover-wrapper :deep(svg) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import ChartBlock from '@/components/ChartBlock.vue'
|
|||
import { useChartsApi } from '@/api/charts.js'
|
||||
import EditLink from '@/views/DetailsView/EditLink.vue'
|
||||
import CopyToClipboard from '@/components/CopyToClipboard.vue'
|
||||
import DetailsViewImages from '@/views/DetailsView/DetailsViewImages.vue'
|
||||
|
||||
const { getMerchDetails, deleteMerch } = useMerchApi()
|
||||
const { getDistinctPrices } = useChartsApi()
|
||||
|
|
@ -102,8 +103,13 @@ onMounted(() => {
|
|||
<div v-else-if="error">Error: {{ error }}</div>
|
||||
<n-card v-else-if="merchDetails" :title="merchDetails.name">
|
||||
<n-divider title-placement="left">Main</n-divider>
|
||||
<div class="container-stackable">
|
||||
<div>
|
||||
<p><strong>Uuid:</strong> {{ merchDetails.merch_uuid }}</p>
|
||||
<p><strong>Name:</strong> {{ merchDetails.name }}</p>
|
||||
</div>
|
||||
<DetailsViewImages :merch-uuid="merchDetails.merch_uuid"/>
|
||||
</div>
|
||||
|
||||
<n-divider title-placement="left">Prices</n-divider>
|
||||
<PeriodSelector @days="handleSelectDays" />
|
||||
|
|
@ -131,7 +137,9 @@ onMounted(() => {
|
|||
</n-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="default-color underline link-like-text" @click="editing.surugaya = true">Add link</span>
|
||||
<span class="default-color underline link-like-text" @click="editing.surugaya = true"
|
||||
>Add link</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
|
@ -164,7 +172,9 @@ onMounted(() => {
|
|||
</n-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="default-color underline link-like-text" @click="editing.mandarake = true">Add link</span>
|
||||
<span class="default-color underline link-like-text" @click="editing.mandarake = true"
|
||||
>Add link</span
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
|
@ -195,4 +205,11 @@ onMounted(() => {
|
|||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.container-stackable {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
180
src/views/DetailsView/DetailsViewImages.vue
Normal file
180
src/views/DetailsView/DetailsViewImages.vue
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NModal, NUpload, useMessage } from 'naive-ui'
|
||||
import BoxIcon from '@/components/icons/BoxIcon.vue'
|
||||
import { useMerchImagesApi } from '@/api/merchImages.js'
|
||||
|
||||
const { uploadImage, getImageUrl, deleteImage } = useMerchImagesApi()
|
||||
const message = useMessage()
|
||||
|
||||
const props = defineProps({
|
||||
merchUuid: { type: String, required: true },
|
||||
})
|
||||
|
||||
const showModal = ref(false)
|
||||
const previewImageUrl = ref('')
|
||||
const fileList = ref([])
|
||||
|
||||
function handlePreview(file) {
|
||||
if (file.file) {
|
||||
previewImageUrl.value = URL.createObjectURL(file.file)
|
||||
} else if (file.url) {
|
||||
previewImageUrl.value = file.url
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function onModalClose() {
|
||||
if (previewImageUrl.value && previewImageUrl.value.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(previewImageUrl.value)
|
||||
}
|
||||
previewImageUrl.value = ''
|
||||
}
|
||||
|
||||
function beforeUpload({ fileList: newFileList }) {
|
||||
return newFileList.length <= 1
|
||||
}
|
||||
|
||||
async function handleUpload({ fileList: newFileList }) {
|
||||
const file = newFileList[newFileList.length - 1]
|
||||
try {
|
||||
await uploadImage(props.merchUuid, file.file)
|
||||
|
||||
const { imgUrl } = await getImageUrl(props.merchUuid, 'full')
|
||||
|
||||
message.success('Image uploaded successfully.')
|
||||
|
||||
fileList.value = [
|
||||
{
|
||||
name: file.name,
|
||||
url: imgUrl,
|
||||
status: 'finished',
|
||||
},
|
||||
]
|
||||
} catch (error) {
|
||||
message.error('Upload error: ' + (error.message || 'Unknown error.'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { imgUrl } = await getImageUrl(props.merchUuid, 'full')
|
||||
fileList.value = [
|
||||
{
|
||||
name: 'full.jpg',
|
||||
url: imgUrl,
|
||||
status: 'finished',
|
||||
},
|
||||
]
|
||||
} catch (error) {
|
||||
fileList.value = []
|
||||
if (!error.message?.includes('404')) {
|
||||
console.error('Error getting image: ', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const showConfirmDelete = ref(false)
|
||||
|
||||
const deleteImageHandler = async () => {
|
||||
showConfirmDelete.value = true
|
||||
return false //prevents instant "delete image" in n-upload before confirm
|
||||
}
|
||||
|
||||
const confirmDeleteImage = async () => {
|
||||
try {
|
||||
await deleteImage(props.merchUuid)
|
||||
fileList.value = []
|
||||
message.success('Image deleted successfully.')
|
||||
} catch (error) {
|
||||
message.error('Image delete error: ' + (error.message || 'Unknown error.'))
|
||||
} finally {
|
||||
showConfirmDelete.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelDelete = () => {
|
||||
showConfirmDelete.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<n-upload
|
||||
v-model:file-list="fileList"
|
||||
:default-upload="false"
|
||||
list-type="image-card"
|
||||
:max="1"
|
||||
:before-upload="beforeUpload"
|
||||
@before-preview="handlePreview"
|
||||
@change="handleUpload"
|
||||
@remove="deleteImageHandler"
|
||||
>
|
||||
<div class="upload-trigger" v-if="fileList.length === 0">
|
||||
<BoxIcon class="upload-icon" />
|
||||
<span class="upload-text">Click to Upload</span>
|
||||
</div>
|
||||
</n-upload>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
preset="card"
|
||||
style="width: 600px"
|
||||
title="Preview"
|
||||
@after-leave="onModalClose"
|
||||
>
|
||||
<img
|
||||
:src="previewImageUrl"
|
||||
style="width: 100%; max-height: 600px; object-fit: contain"
|
||||
alt="Preview"
|
||||
/>
|
||||
</n-modal>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="showConfirmDelete" preset="dialog" title="Confirmation">
|
||||
<template #default>
|
||||
<p>Confirm delete image</p>
|
||||
</template>
|
||||
<template #action>
|
||||
<n-button @click="confirmDeleteImage" type="error">Delete</n-button>
|
||||
<n-button @click="cancelDelete">Cancel</n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.upload-trigger {
|
||||
width: 300px;
|
||||
max-height: 600px;
|
||||
min-height: 180px;
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 8px;
|
||||
background-color: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-trigger:hover {
|
||||
border-color: #18a058;
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue