This commit is contained in:
2026-09-30 09:53:30 +03:30
parent 4d27da98fc
commit ebfbeb7a88
64 changed files with 1275 additions and 626 deletions
+58 -97
View File
@@ -8,16 +8,17 @@ import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
import AdminIcon from '@/components/icons/AdminIcon.vue'
import AdminSelect from '@/components/ui/AdminSelect.vue'
import { catalogsApi, productsApi, settingsApi } from '@/services/api'
import { brandsApi, catalogsApi, productsApi, settingsApi, type Brand } from '@/services/api'
import { ApiError } from '@/services/http'
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, toCssBackgroundUrl } from '@/utils/catalogImage'
import { toCssBackgroundUrl } from '@/utils/catalogImage'
const productList = ref<Product[]>([])
const catalogs = ref<Catalog[]>([])
const brands = ref<Brand[]>([])
const usageOptions = ref<Array<{ value: string; label: string }>>([
{ value: 'trousers', label: 'شلوار' },
{ value: 'suit', label: 'کت‌وشلوار' },
@@ -53,8 +54,16 @@ const catalogFormOptions = computed(() =>
})),
)
const brandFormOptions = computed(() => [
{ value: '', label: 'بدون برند' },
...brands.value
.filter((b) => b.isActive)
.map((b) => ({ value: b.id, label: b.name })),
])
const emptyForm = () => ({
catalogId: catalogs.value[0]?.id ?? '',
brandId: '',
name: '',
color: '',
productCode: '',
@@ -70,8 +79,11 @@ const emptyForm = () => ({
subtitle: '',
rollMeters: 50,
isNew: false,
colorRows: [{ name: '', imageUrl: '' }] as Array<{ name: string; imageUrl: string }>,
extraImageUrls: [] as string[],
colorRows: [{ id: crypto.randomUUID(), name: '', imageUrl: '' }] as Array<{
id: string
name: string
imageUrl: string
}>,
})
const form = reactive(emptyForm())
@@ -104,13 +116,15 @@ const filteredProducts = computed(() => {
async function loadData() {
loading.value = true
try {
const [productsData, catalogsData, settings] = await Promise.all([
const [productsData, catalogsData, brandsData, settings] = await Promise.all([
productsApi.list(),
catalogsApi.list(),
brandsApi.list(),
settingsApi.get().catch(() => null),
])
productList.value = productsData
catalogs.value = catalogsData
brands.value = brandsData
if (settings?.categoryDefsJson?.usage?.length) {
usageOptions.value = [
...settings.categoryDefsJson.usage.map((item) => ({
@@ -152,6 +166,7 @@ function openEditModal(product: Product) {
modalMode.value = 'edit'
editingId.value = product.id
form.catalogId = product.catalogId
form.brandId = product.brandId ?? ''
form.name = product.name
form.color = product.color
form.productCode = product.productCode
@@ -172,53 +187,47 @@ function openEditModal(product: Product) {
const gallery = [...(product.imageUrls ?? [])]
if (colors.length) {
form.colorRows = colors.map((name, index) => ({
id: crypto.randomUUID(),
name,
imageUrl: colorImages[index] || gallery[index] || '',
}))
const used = new Set(form.colorRows.map((row) => row.imageUrl).filter(Boolean))
form.extraImageUrls = gallery.filter((url) => !used.has(url))
for (const url of gallery) {
if (!used.has(url)) {
form.colorRows.push({
id: crypto.randomUUID(),
name: `رنگ ${form.colorRows.length + 1}`,
imageUrl: url,
})
used.add(url)
}
}
} else if (gallery.length) {
form.colorRows = gallery.map((url, index) => ({
id: crypto.randomUUID(),
name: index === 0 ? product.color || '' : `رنگ ${index + 1}`,
imageUrl: url,
}))
} else {
form.colorRows = [{ name: product.color || '', imageUrl: gallery[0] || '' }]
form.extraImageUrls = gallery.slice(1)
form.colorRows = [
{ id: crypto.randomUUID(), name: product.color || '', imageUrl: '' },
]
}
formError.value = ''
}
function addColorRow() {
form.colorRows.push({ name: '', imageUrl: '' })
form.colorRows.push({ id: crypto.randomUUID(), name: '', imageUrl: '' })
}
function removeColorRow(index: number) {
if (form.colorRows.length <= 1) {
form.colorRows[0] = { name: '', imageUrl: '' }
form.colorRows[0] = { id: form.colorRows[0]?.id ?? crypto.randomUUID(), name: '', imageUrl: '' }
return
}
form.colorRows.splice(index, 1)
}
const imageBusy = ref(false)
async function addProductImages(event: Event) {
const input = event.target as HTMLInputElement
const files = [...(input.files ?? [])]
input.value = ''
if (!files.length) return
imageBusy.value = true
formError.value = ''
try {
const next = await Promise.all(files.map((file) => fileToCatalogImage(file)))
form.extraImageUrls = [...form.extraImageUrls, ...next]
} catch (e) {
formError.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
} finally {
imageBusy.value = false
}
}
function removeProductImage(index: number) {
form.extraImageUrls.splice(index, 1)
}
function buildProductMedia() {
const colorEntries: Array<{ name: string; imageUrl?: string }> = []
const imageUrls: string[] = []
@@ -244,8 +253,6 @@ function buildProductMedia() {
pushImage(imageUrl)
})
form.extraImageUrls.forEach((url) => pushImage(url))
return { colorEntries, imageUrls }
}
@@ -294,6 +301,7 @@ async function submitProduct() {
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
brandId: form.brandId || null,
...catalogPayload,
})
const index = productList.value.findIndex((p) => p.id === editingId.value)
@@ -312,6 +320,7 @@ async function submitProduct() {
price: form.price,
internalStock: form.internalStock,
isActive: form.isActive,
brandId: form.brandId || null,
...catalogPayload,
})
productList.value.push(created)
@@ -396,6 +405,7 @@ function clearFilters() {
<th>کد محصول</th>
<th>نام</th>
<th>رنگ</th>
<th>برند</th>
<th>کالیته</th>
<th>شرکت</th>
<th class="numeric">حداقل متراژ</th>
@@ -418,6 +428,7 @@ function clearFilters() {
<td>{{ product.productCode }}</td>
<td>{{ product.name }}</td>
<td>{{ product.color }}</td>
<td>{{ product.brandName || '—' }}</td>
<td>{{ product.catalogCode }}</td>
<td>{{ product.companyName }}</td>
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
@@ -485,6 +496,17 @@ function clearFilters() {
/>
</div>
<div class="admin-form__field">
<label>برند</label>
<AdminSelect
v-model="form.brandId"
block
aria-label="برند محصول"
placeholder="انتخاب برند"
:options="brandFormOptions"
/>
</div>
<div class="admin-form__row">
<div class="admin-form__field">
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
@@ -571,7 +593,7 @@ function clearFilters() {
تصویر با زدن «ذخیره تغییرات» روی سرور ثبت می‌شود.
</p>
<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="row.id" class="color-variant">
<AdminImagePicker
:model-value="row.imageUrl || null"
@update:model-value="row.imageUrl = $event ?? ''"
@@ -594,27 +616,6 @@ function clearFilters() {
</div>
</div>
<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: toCssBackgroundUrl(src) }" />
<button type="button" class="product-images__remove" @click="removeProductImage(index)">
حذف
</button>
</div>
<label class="product-images__add">
<input type="file" accept="image/*" multiple hidden :disabled="imageBusy" @change="addProductImages" />
{{ imageBusy ? '...' : '+ افزودن تصویر' }}
</label>
</div>
</div>
<div class="admin-form__field">
<BaseSwitch v-model="form.isNew" label="محصول جدید" />
</div>
@@ -714,44 +715,4 @@ function clearFilters() {
align-items: flex-start;
}
.product-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.product-images__item {
display: flex;
flex-direction: column;
gap: 6px;
width: 92px;
}
.product-images__thumb {
display: block;
width: 92px;
height: 72px;
border-radius: 10px;
background: #e8eceb center / cover no-repeat;
border: 1px solid var(--color-border);
}
.product-images__remove,
.product-images__add {
border: 1px dashed var(--color-border);
background: transparent;
border-radius: 8px;
padding: 6px 8px;
font-size: 0.75rem;
cursor: pointer;
text-align: center;
}
.product-images__add {
display: grid;
place-items: center;
min-width: 92px;
min-height: 72px;
color: var(--color-text-muted);
}
</style>