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>
|
<template>
|
||||||
<svg
|
<svg
|
||||||
height="800px"
|
|
||||||
width="800px"
|
|
||||||
version="1.1"
|
version="1.1"
|
||||||
id="Layer_1"
|
id="Layer_1"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||||
viewBox="0 0 512 512"
|
viewBox="0 0 512 512"
|
||||||
xml:space="preserve"
|
xml:space="preserve"
|
||||||
|
style="width: 100%; height: 100%; display: block;"
|
||||||
>
|
>
|
||||||
<polygon
|
<polygon
|
||||||
style="fill: #e0b76e"
|
style="fill: #e0b76e"
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,17 @@ export const apiClient = {
|
||||||
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
const fullUrl = queryString ? `${url}?${queryString}` : url;
|
||||||
return request(fullUrl, { method: 'GET' });
|
return request(fullUrl, { method: 'GET' });
|
||||||
},
|
},
|
||||||
post: (url, data) => request(url, {
|
post: (url, data) => {
|
||||||
method: 'POST',
|
const isFormData = data instanceof FormData
|
||||||
body: JSON.stringify(data),
|
|
||||||
}),
|
return request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: isFormData ? data : JSON.stringify(data),
|
||||||
|
headers: isFormData
|
||||||
|
? {}
|
||||||
|
: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
},
|
||||||
put: (url, data) => request(url, {
|
put: (url, data) => request(url, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
export function convertIso(isoString) {
|
export function convertIso(isoString) {
|
||||||
const date = new Date(isoString);
|
const date = new Date(isoString);
|
||||||
|
|
||||||
// Извлекаем компоненты времени и даты
|
|
||||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||||
const day = String(date.getUTCDate()).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();
|
const year = date.getUTCFullYear();
|
||||||
|
|
||||||
return `${hours}:${minutes}:${seconds} ${day}-${month}-${year}`;
|
return `${hours}:${minutes}:${seconds} ${day}-${month}-${year}`;
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,36 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import BoxIcon from '@/components/icons/BoxIcon.vue'
|
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: {
|
merch: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -16,7 +40,13 @@ defineProps({
|
||||||
</template>
|
</template>
|
||||||
<template #cover>
|
<template #cover>
|
||||||
<div class="cover-wrapper">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</n-card>
|
</n-card>
|
||||||
|
|
@ -52,6 +82,13 @@ defineProps({
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
max-width: 80%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.cover-wrapper :deep(svg) {
|
.cover-wrapper :deep(svg) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import ChartBlock from '@/components/ChartBlock.vue'
|
||||||
import { useChartsApi } from '@/api/charts.js'
|
import { useChartsApi } from '@/api/charts.js'
|
||||||
import EditLink from '@/views/DetailsView/EditLink.vue'
|
import EditLink from '@/views/DetailsView/EditLink.vue'
|
||||||
import CopyToClipboard from '@/components/CopyToClipboard.vue'
|
import CopyToClipboard from '@/components/CopyToClipboard.vue'
|
||||||
|
import DetailsViewImages from '@/views/DetailsView/DetailsViewImages.vue'
|
||||||
|
|
||||||
const { getMerchDetails, deleteMerch } = useMerchApi()
|
const { getMerchDetails, deleteMerch } = useMerchApi()
|
||||||
const { getDistinctPrices } = useChartsApi()
|
const { getDistinctPrices } = useChartsApi()
|
||||||
|
|
@ -102,8 +103,13 @@ onMounted(() => {
|
||||||
<div v-else-if="error">Error: {{ error }}</div>
|
<div v-else-if="error">Error: {{ error }}</div>
|
||||||
<n-card v-else-if="merchDetails" :title="merchDetails.name">
|
<n-card v-else-if="merchDetails" :title="merchDetails.name">
|
||||||
<n-divider title-placement="left">Main</n-divider>
|
<n-divider title-placement="left">Main</n-divider>
|
||||||
<p><strong>Uuid:</strong> {{ merchDetails.merch_uuid }}</p>
|
<div class="container-stackable">
|
||||||
<p><strong>Name:</strong> {{ merchDetails.name }}</p>
|
<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>
|
<n-divider title-placement="left">Prices</n-divider>
|
||||||
<PeriodSelector @days="handleSelectDays" />
|
<PeriodSelector @days="handleSelectDays" />
|
||||||
|
|
@ -131,7 +137,9 @@ onMounted(() => {
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -164,7 +172,9 @@ onMounted(() => {
|
||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -195,4 +205,11 @@ onMounted(() => {
|
||||||
</n-modal>
|
</n-modal>
|
||||||
</template>
|
</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