Fix admin product image handling and clarify support phone settings.

This commit is contained in:
2026-09-16 20:04:12 +03:30
parent 2de2f697f1
commit ab5ce96529
6 changed files with 71 additions and 17 deletions
+14 -2
View File
@@ -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
View File
@@ -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 } }),
}
+2 -1
View File
@@ -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 =
+6
View File
@@ -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('فقط فایل تصویری مجاز است.')
+43 -11
View File
@@ -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>
+3 -1
View File
@@ -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">