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