Fix admin product image handling and clarify support phone settings.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { fileToCatalogImage } from '@/utils/catalogImage'
|
import { fileToCatalogImage, toCssBackgroundUrl } from '@/utils/catalogImage'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: string | null
|
modelValue?: string | null
|
||||||
@@ -14,6 +14,7 @@ const emit = defineEmits<{
|
|||||||
const inputRef = ref<HTMLInputElement | null>(null)
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
const picked = ref(false)
|
||||||
|
|
||||||
function openPicker() {
|
function openPicker() {
|
||||||
inputRef.value?.click()
|
inputRef.value?.click()
|
||||||
@@ -26,8 +27,10 @@ async function onFileChange(event: Event) {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
busy.value = true
|
busy.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
picked.value = false
|
||||||
try {
|
try {
|
||||||
emit('update:modelValue', await fileToCatalogImage(file))
|
emit('update:modelValue', await fileToCatalogImage(file))
|
||||||
|
picked.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
|
error.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -47,7 +50,7 @@ function remove() {
|
|||||||
<div
|
<div
|
||||||
class="image-picker__preview"
|
class="image-picker__preview"
|
||||||
:class="{ 'image-picker__preview--empty': !props.modelValue }"
|
:class="{ 'image-picker__preview--empty': !props.modelValue }"
|
||||||
:style="props.modelValue ? { backgroundImage: `url(${props.modelValue})` } : undefined"
|
:style="props.modelValue ? { backgroundImage: toCssBackgroundUrl(props.modelValue) } : undefined"
|
||||||
/>
|
/>
|
||||||
<div class="image-picker__actions">
|
<div class="image-picker__actions">
|
||||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" :disabled="busy" @click="openPicker">
|
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" :disabled="busy" @click="openPicker">
|
||||||
@@ -63,6 +66,7 @@ function remove() {
|
|||||||
حذف
|
حذف
|
||||||
</button>
|
</button>
|
||||||
<p v-if="error" class="image-picker__error">{{ error }}</p>
|
<p v-if="error" class="image-picker__error">{{ error }}</p>
|
||||||
|
<p v-else-if="picked" class="image-picker__ok">تصویر انتخاب شد — برای ثبت نهایی «ذخیره» را بزنید.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -124,4 +128,12 @@ function remove() {
|
|||||||
color: var(--color-danger);
|
color: var(--color-danger);
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-picker__ok {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--color-success-dark);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+3
-2
@@ -189,9 +189,10 @@ export const productsApi = {
|
|||||||
activeOnly: params?.activeOnly ? 'true' : undefined,
|
activeOnly: params?.activeOnly ? 'true' : undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
create: (body: CreateProductPayload) => apiRequest<Product>('/products', { body }),
|
create: (body: CreateProductPayload) =>
|
||||||
|
apiRequest<Product>('/products', { body, timeoutMs: 90_000 }),
|
||||||
update: (id: string, body: UpdateProductPayload) =>
|
update: (id: string, body: UpdateProductPayload) =>
|
||||||
apiRequest<Product>(`/products/${id}`, { method: 'PATCH', body }),
|
apiRequest<Product>(`/products/${id}`, { method: 'PATCH', body, timeoutMs: 90_000 }),
|
||||||
setActive: (id: string, isActive: boolean) =>
|
setActive: (id: string, isActive: boolean) =>
|
||||||
apiRequest<Product>(`/products/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
apiRequest<Product>(`/products/${id}/active`, { method: 'PATCH', body: { isActive } }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type RequestOptions = {
|
|||||||
body?: unknown
|
body?: unknown
|
||||||
auth?: boolean
|
auth?: boolean
|
||||||
query?: Record<string, string | number | boolean | undefined | null>
|
query?: Record<string, string | number | boolean | undefined | null>
|
||||||
|
timeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
function buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||||
@@ -86,7 +87,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
method: options.method ?? (options.body !== undefined ? 'POST' : 'GET'),
|
method: options.method ?? (options.body !== undefined ? 'POST' : 'GET'),
|
||||||
headers,
|
headers,
|
||||||
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
|
||||||
signal: AbortSignal.timeout(20_000),
|
signal: AbortSignal.timeout(options.timeoutMs ?? 20_000),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const timedOut =
|
const timedOut =
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
const MAX_EDGE = 960
|
const MAX_EDGE = 960
|
||||||
const JPEG_QUALITY = 0.82
|
const JPEG_QUALITY = 0.82
|
||||||
|
|
||||||
|
export function toCssBackgroundUrl(url: string): string {
|
||||||
|
const trimmed = url.trim()
|
||||||
|
if (!trimmed) return ''
|
||||||
|
return `url("${trimmed.replace(/"/g, '\\"')}")`
|
||||||
|
}
|
||||||
|
|
||||||
export async function fileToCatalogImage(file: File): Promise<string> {
|
export async function fileToCatalogImage(file: File): Promise<string> {
|
||||||
if (!file.type.startsWith('image/')) {
|
if (!file.type.startsWith('image/')) {
|
||||||
throw new Error('فقط فایل تصویری مجاز است.')
|
throw new Error('فقط فایل تصویری مجاز است.')
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useToast } from '@/composables/useToast'
|
|||||||
import { formatCurrency, formatNumber } from '@/utils/format'
|
import { formatCurrency, formatNumber } from '@/utils/format'
|
||||||
import type { Catalog, Product } from '@/types'
|
import type { Catalog, Product } from '@/types'
|
||||||
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
|
import AdminCurrencyInput from '@/components/ui/AdminCurrencyInput.vue'
|
||||||
import { fileToCatalogImage } from '@/utils/catalogImage'
|
import { fileToCatalogImage, toCssBackgroundUrl } from '@/utils/catalogImage'
|
||||||
|
|
||||||
const productList = ref<Product[]>([])
|
const productList = ref<Product[]>([])
|
||||||
const catalogs = ref<Catalog[]>([])
|
const catalogs = ref<Catalog[]>([])
|
||||||
@@ -219,6 +219,36 @@ function removeProductImage(index: number) {
|
|||||||
form.extraImageUrls.splice(index, 1)
|
form.extraImageUrls.splice(index, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProductMedia() {
|
||||||
|
const colorEntries: Array<{ name: string; imageUrl?: string }> = []
|
||||||
|
const imageUrls: string[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
const pushImage = (url?: string) => {
|
||||||
|
const trimmed = url?.trim()
|
||||||
|
if (!trimmed || seen.has(trimmed)) return
|
||||||
|
seen.add(trimmed)
|
||||||
|
imageUrls.push(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
form.colorRows.forEach((row, index) => {
|
||||||
|
const imageUrl = row.imageUrl.trim() || undefined
|
||||||
|
const name =
|
||||||
|
row.name.trim() ||
|
||||||
|
(index === 0 ? form.color.trim() : '') ||
|
||||||
|
(imageUrl ? `رنگ ${index + 1}` : '')
|
||||||
|
if (!name && !imageUrl) return
|
||||||
|
if (name) {
|
||||||
|
colorEntries.push({ name, imageUrl })
|
||||||
|
}
|
||||||
|
pushImage(imageUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
form.extraImageUrls.forEach((url) => pushImage(url))
|
||||||
|
|
||||||
|
return { colorEntries, imageUrls }
|
||||||
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
modalMode.value = null
|
modalMode.value = null
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
@@ -237,12 +267,7 @@ async function submitProduct() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorEntries = form.colorRows
|
const { colorEntries, imageUrls } = buildProductMedia()
|
||||||
.map((row) => ({
|
|
||||||
name: row.name.trim(),
|
|
||||||
imageUrl: row.imageUrl.trim() || undefined,
|
|
||||||
}))
|
|
||||||
.filter((row) => row.name)
|
|
||||||
const catalogPayload = {
|
const catalogPayload = {
|
||||||
usageCategory: form.usageCategory,
|
usageCategory: form.usageCategory,
|
||||||
season: form.season,
|
season: form.season,
|
||||||
@@ -252,7 +277,7 @@ async function submitProduct() {
|
|||||||
rollMeters: form.rollMeters,
|
rollMeters: form.rollMeters,
|
||||||
isNew: form.isNew,
|
isNew: form.isNew,
|
||||||
colorsJson: colorEntries,
|
colorsJson: colorEntries,
|
||||||
imageUrlsJson: form.extraImageUrls,
|
imageUrlsJson: imageUrls,
|
||||||
}
|
}
|
||||||
const productCode = form.productCode.trim() || `${cat.code}-NEW`
|
const productCode = form.productCode.trim() || `${cat.code}-NEW`
|
||||||
saving.value = true
|
saving.value = true
|
||||||
@@ -294,6 +319,9 @@ async function submitProduct() {
|
|||||||
}
|
}
|
||||||
closeModal()
|
closeModal()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.status === 0) {
|
||||||
|
formError.value = err.message
|
||||||
|
}
|
||||||
toast.error(err instanceof ApiError ? err.message : 'ذخیره محصول انجام نشد.')
|
toast.error(err instanceof ApiError ? err.message : 'ذخیره محصول انجام نشد.')
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -384,7 +412,7 @@ function clearFilters() {
|
|||||||
<span
|
<span
|
||||||
class="product-thumb"
|
class="product-thumb"
|
||||||
:class="{ 'product-thumb--empty': !product.imageUrls?.[0] }"
|
:class="{ 'product-thumb--empty': !product.imageUrls?.[0] }"
|
||||||
:style="product.imageUrls?.[0] ? { backgroundImage: `url(${product.imageUrls[0]})` } : undefined"
|
:style="product.imageUrls?.[0] ? { backgroundImage: toCssBackgroundUrl(product.imageUrls[0]) } : undefined"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ product.productCode }}</td>
|
<td>{{ product.productCode }}</td>
|
||||||
@@ -538,7 +566,10 @@ function clearFilters() {
|
|||||||
|
|
||||||
<div class="admin-form__field">
|
<div class="admin-form__field">
|
||||||
<label>رنگها و تصویر هر رنگ</label>
|
<label>رنگها و تصویر هر رنگ</label>
|
||||||
<p class="color-variants__hint">برای هر رنگ یک نام و یک عکس جدا بگذارید؛ در فروشگاه با انتخاب همان رنگ، همان عکس نمایش داده میشود.</p>
|
<p class="color-variants__hint">
|
||||||
|
برای هر رنگ نام و عکس بگذارید. اگر فقط عکس دارید، نام رنگ اصلی (بالا) یا «رنگ ۱» استفاده میشود.
|
||||||
|
تصویر با زدن «ذخیره تغییرات» روی سرور ثبت میشود.
|
||||||
|
</p>
|
||||||
<div class="color-variants">
|
<div class="color-variants">
|
||||||
<div v-for="(row, index) in form.colorRows" :key="index" class="color-variant">
|
<div v-for="(row, index) in form.colorRows" :key="index" class="color-variant">
|
||||||
<AdminImagePicker
|
<AdminImagePicker
|
||||||
@@ -565,13 +596,14 @@ function clearFilters() {
|
|||||||
|
|
||||||
<div class="admin-form__field">
|
<div class="admin-form__field">
|
||||||
<label>تصاویر عمومی محصول (اختیاری)</label>
|
<label>تصاویر عمومی محصول (اختیاری)</label>
|
||||||
|
<p class="color-variants__hint">پس از انتخاب فایل، حتماً دکمه ذخیره را بزنید.</p>
|
||||||
<div class="product-images">
|
<div class="product-images">
|
||||||
<div
|
<div
|
||||||
v-for="(src, index) in form.extraImageUrls"
|
v-for="(src, index) in form.extraImageUrls"
|
||||||
:key="`${index}-${src.slice(0, 24)}`"
|
:key="`${index}-${src.slice(0, 24)}`"
|
||||||
class="product-images__item"
|
class="product-images__item"
|
||||||
>
|
>
|
||||||
<span class="product-images__thumb" :style="{ backgroundImage: `url(${src})` }" />
|
<span class="product-images__thumb" :style="{ backgroundImage: toCssBackgroundUrl(src) }" />
|
||||||
<button type="button" class="product-images__remove" @click="removeProductImage(index)">
|
<button type="button" class="product-images__remove" @click="removeProductImage(index)">
|
||||||
حذف
|
حذف
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -241,7 +241,9 @@ async function sendBroadcast() {
|
|||||||
<div class="admin-form__field">
|
<div class="admin-form__field">
|
||||||
<label for="support-phone">تلفن پشتیبانی</label>
|
<label for="support-phone">تلفن پشتیبانی</label>
|
||||||
<input id="support-phone" v-model="settings.supportPhone" type="text" class="admin-input" dir="ltr" />
|
<input id="support-phone" v-model="settings.supportPhone" type="text" class="admin-input" dir="ltr" />
|
||||||
<p class="settings-page__hint">این شماره در صفحه ورود اپ (پشتیبانی و تماس با ما) نمایش داده میشود.</p>
|
<p class="settings-page__hint">
|
||||||
|
این شماره در صفحه ورود اپ و بخش پشتیبانی سایدبار دسکتاپ نمایش داده میشود.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-form__field">
|
<div class="admin-form__field">
|
||||||
|
|||||||
Reference in New Issue
Block a user