change UI & fix bugs

This commit is contained in:
2026-09-14 16:10:30 +03:30
parent 33a3828aaf
commit a349526385
78 changed files with 1214 additions and 506 deletions
+29 -1
View File
@@ -173,7 +173,7 @@ export type CreateProductPayload = {
widthCm?: number | null
weightGrams?: number | null
isNew?: boolean
colorsJson?: string[]
colorsJson?: Array<string | { name: string; imageUrl?: string }>
imageUrlsJson?: string[]
tagsJson?: string[]
}
@@ -439,6 +439,28 @@ export type SystemSettings = {
supportPhone: string | null
supportEmail: string | null
categoryImagesJson?: Record<string, string>
categoryDefsJson?: {
usage: Array<{
id: string
title: string
subtitle: string
showTitle?: boolean
showSubtitle?: boolean
showIcon?: boolean
icon?: string
}>
seasons: Array<{
id: string
title: string
description: string
showTitle?: boolean
showDescription?: boolean
showCta?: boolean
showIcon?: boolean
ctaLabel?: string
icon?: string
}>
}
updatedAt: string
}
@@ -531,6 +553,9 @@ export type AppBanner = {
imageUrl: string
displayOrder: number
isActive: boolean
showTitle?: boolean
showSubtitle?: boolean
showCta?: boolean
}
export type BannerPayload = {
@@ -542,6 +567,9 @@ export type BannerPayload = {
imageUrl?: string | null
displayOrder?: number
isActive?: boolean
showTitle?: boolean
showSubtitle?: boolean
showCta?: boolean
}
export const bannersApi = {
+1
View File
@@ -233,6 +233,7 @@ export interface Product {
rollMeters?: number
isNew?: boolean
colors?: string[]
colorImages?: Array<string | null>
imageUrls?: string[]
}
+40 -3
View File
@@ -31,6 +31,9 @@ const form = reactive({
imageUrl: null as string | null,
displayOrder: 0,
isActive: true,
showTitle: true,
showSubtitle: true,
showCta: true,
})
const modalTitle = computed(() => (editingId.value ? 'ویرایش بنر' : 'افزودن بنر'))
@@ -61,6 +64,9 @@ function openAdd() {
imageUrl: null,
displayOrder: list.value.length,
isActive: true,
showTitle: true,
showSubtitle: true,
showCta: true,
})
modalOpen.value = true
}
@@ -76,6 +82,9 @@ function openEdit(banner: AppBanner) {
imageUrl: banner.imageUrl || null,
displayOrder: banner.displayOrder,
isActive: banner.isActive,
showTitle: banner.showTitle !== false,
showSubtitle: banner.showSubtitle !== false,
showCta: banner.showCta !== false,
})
modalOpen.value = true
}
@@ -93,6 +102,9 @@ async function save() {
imageUrl: form.imageUrl,
displayOrder: form.displayOrder,
isActive: form.isActive,
showTitle: form.showTitle,
showSubtitle: form.showSubtitle,
showCta: form.showCta,
}
if (editingId.value) await bannersApi.update(editingId.value, payload)
else await bannersApi.create(payload)
@@ -185,9 +197,27 @@ onMounted(() => {
بخش نمایش
<AdminSelect v-model="form.slot" :options="SLOT_OPTIONS" />
</label>
<label class="field">عنوان<input v-model="form.title" class="admin-input" /></label>
<label class="field">زیرعنوان<input v-model="form.subtitle" class="admin-input" /></label>
<label class="field">متن دکمه<input v-model="form.ctaLabel" class="admin-input" /></label>
<div class="field">
<span class="field__head">
عنوان
<BaseSwitch v-model="form.showTitle" active-text="نمایش" inactive-text="مخفی" />
</span>
<input v-model="form.title" class="admin-input" />
</div>
<div class="field">
<span class="field__head">
زیرعنوان
<BaseSwitch v-model="form.showSubtitle" active-text="نمایش" inactive-text="مخفی" />
</span>
<input v-model="form.subtitle" class="admin-input" />
</div>
<div class="field">
<span class="field__head">
متن دکمه
<BaseSwitch v-model="form.showCta" active-text="نمایش" inactive-text="مخفی" />
</span>
<input v-model="form.ctaLabel" class="admin-input" />
</div>
<label class="field">
لینک دکمه
<input v-model="form.ctaLink" class="admin-input" dir="ltr" placeholder="/categories" />
@@ -237,4 +267,11 @@ onMounted(() => {
margin-bottom: 12px;
font-size: 0.875rem;
}
.field__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
</style>
+592 -49
View File
@@ -1,51 +1,369 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
import { settingsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
const USAGE = [
{ id: 'trousers', title: 'پارچه‌های شلواری' },
{ id: 'suit', title: 'پارچه‌های کت‌شلواری' },
{ id: 'shirt', title: 'پارچه‌های پیراهنی' },
]
type UsageDef = {
id: string
title: string
subtitle: string
icon: string
showTitle: boolean
showSubtitle: boolean
showIcon: boolean
}
type SeasonDef = {
id: string
title: string
description: string
ctaLabel: string
icon: string
showTitle: boolean
showDescription: boolean
showCta: boolean
showIcon: boolean
}
const SEASONS = [
{ id: 'summer', title: 'تابستانه' },
{ id: 'winter', title: 'زمستانه' },
{ id: 'all_season', title: 'چهارفصل' },
]
const images = reactive<Record<string, string | null>>({
trousers: null,
suit: null,
shirt: null,
summer: null,
winter: null,
all_season: null,
})
const usage = ref<UsageDef[]>([])
const seasons = ref<SeasonDef[]>([])
const images = reactive<Record<string, string | null>>({})
const loading = ref(true)
const saving = ref(false)
const toast = useToast()
const showAddUsage = ref(false)
const showAddSeason = ref(false)
const ICON_OPTIONS = [
{ value: 'bolt', label: 'آذرخش' },
{ value: 'feather', label: 'پر' },
{ value: 'diamond', label: 'الماس' },
{ value: 'grid', label: 'شبکه' },
{ value: 'bag', label: 'کیف' },
{ value: 'id-card', label: 'کارت' },
{ value: 'catalog', label: 'کالیته' },
{ value: 'headset', label: 'پشتیبانی' },
{ value: 'home', label: 'خانه' },
]
function defaultUsageIcon(id: string) {
if (id === 'shirt') return 'id-card'
if (id === 'suit') return 'bag'
return 'grid'
}
function defaultSeasonIcon(id: string) {
if (id === 'summer') return 'bolt'
if (id === 'winter') return 'feather'
return 'diamond'
}
const newUsage = reactive({
id: '',
title: '',
subtitle: 'مشاهده محصولات',
icon: 'grid',
showTitle: true,
showSubtitle: true,
showIcon: true,
})
const newSeason = reactive({
id: '',
title: '',
description: '',
ctaLabel: 'مشاهده محصولات',
icon: 'diamond',
showTitle: true,
showDescription: true,
showCta: true,
showIcon: true,
})
function visibleFlag(value: unknown) {
return value !== false
}
const usageIds = computed(() => usage.value.map((item) => item.id))
const seasonIds = computed(() => seasons.value.map((item) => item.id))
function slugify(raw: string) {
return raw
.trim()
.toLowerCase()
.replace(/\s+/g, '_')
.replace(/[^a-z0-9_\u0600-\u06ff-]/gi, '')
.slice(0, 48)
}
function syncImageKeys() {
const keys = [...usageIds.value, ...seasonIds.value]
for (const key of keys) {
if (!(key in images)) images[key] = null
}
for (const key of Object.keys(images)) {
if (!keys.includes(key)) delete images[key]
}
}
async function load() {
loading.value = true
try {
const data = await settingsApi.get()
usage.value = data.categoryDefsJson?.usage?.length
? data.categoryDefsJson.usage.map((item) => ({
id: item.id,
title: item.title,
subtitle: item.subtitle || 'مشاهده محصولات',
icon: item.icon || defaultUsageIcon(item.id),
showTitle: visibleFlag(item.showTitle),
showSubtitle: visibleFlag(item.showSubtitle),
showIcon: visibleFlag(item.showIcon),
}))
: [
{
id: 'trousers',
title: 'پارچه‌های شلواری',
subtitle: 'مشاهده محصولات',
icon: 'grid',
showTitle: true,
showSubtitle: true,
showIcon: true,
},
{
id: 'suit',
title: 'پارچه‌های کت‌شلواری',
subtitle: 'مشاهده محصولات',
icon: 'bag',
showTitle: true,
showSubtitle: true,
showIcon: true,
},
{
id: 'shirt',
title: 'پارچه‌های پیراهنی',
subtitle: 'مشاهده محصولات',
icon: 'id-card',
showTitle: true,
showSubtitle: true,
showIcon: true,
},
]
seasons.value = data.categoryDefsJson?.seasons?.length
? data.categoryDefsJson.seasons.map((item) => ({
id: item.id,
title: item.title,
description: item.description || '',
ctaLabel: item.ctaLabel || 'مشاهده محصولات',
icon: item.icon || defaultSeasonIcon(item.id),
showTitle: visibleFlag(item.showTitle),
showDescription: visibleFlag(item.showDescription),
showCta: visibleFlag(item.showCta),
showIcon: visibleFlag(item.showIcon),
}))
: [
{
id: 'summer',
title: 'تابستانه',
description: 'سبک، خنک و مناسب فصل گرم',
ctaLabel: 'مشاهده محصولات',
icon: 'bolt',
showTitle: true,
showDescription: true,
showCta: true,
showIcon: true,
},
{
id: 'winter',
title: 'زمستانه',
description: 'گرم و مناسب فصل سرد',
ctaLabel: 'مشاهده محصولات',
icon: 'feather',
showTitle: true,
showDescription: true,
showCta: true,
showIcon: true,
},
{
id: 'all_season',
title: 'چهارفصل',
description: 'مناسب استفاده در بیشتر فصل‌های سال',
ctaLabel: 'مشاهده محصولات',
icon: 'diamond',
showTitle: true,
showDescription: true,
showCta: true,
showIcon: true,
},
]
syncImageKeys()
const stored = data.categoryImagesJson ?? {}
for (const key of Object.keys(images)) {
images[key] = stored[key] ?? null
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری تصاویر دسته‌ها ناموفق بود.')
toast.error(err instanceof ApiError ? err.message : 'بارگذاری دسته‌ها ناموفق بود.')
if (!usage.value.length) {
usage.value = [
{
id: 'trousers',
title: 'پارچه‌های شلواری',
subtitle: 'مشاهده محصولات',
icon: 'grid',
showTitle: true,
showSubtitle: true,
showIcon: true,
},
{
id: 'suit',
title: 'پارچه‌های کت‌شلواری',
subtitle: 'مشاهده محصولات',
icon: 'bag',
showTitle: true,
showSubtitle: true,
showIcon: true,
},
{
id: 'shirt',
title: 'پارچه‌های پیراهنی',
subtitle: 'مشاهده محصولات',
icon: 'id-card',
showTitle: true,
showSubtitle: true,
showIcon: true,
},
]
}
if (!seasons.value.length) {
seasons.value = [
{
id: 'summer',
title: 'تابستانه',
description: 'سبک، خنک و مناسب فصل گرم',
ctaLabel: 'مشاهده محصولات',
icon: 'bolt',
showTitle: true,
showDescription: true,
showCta: true,
showIcon: true,
},
{
id: 'winter',
title: 'زمستانه',
description: 'گرم و مناسب فصل سرد',
ctaLabel: 'مشاهده محصولات',
icon: 'feather',
showTitle: true,
showDescription: true,
showCta: true,
showIcon: true,
},
{
id: 'all_season',
title: 'چهارفصل',
description: 'مناسب استفاده در بیشتر فصل‌های سال',
ctaLabel: 'مشاهده محصولات',
icon: 'diamond',
showTitle: true,
showDescription: true,
showCta: true,
showIcon: true,
},
]
}
syncImageKeys()
} finally {
loading.value = false
}
}
function addUsage() {
const id = slugify(newUsage.id || newUsage.title)
const title = newUsage.title.trim()
if (!id || !title) {
toast.error('شناسه و عنوان دسته کاربرد الزامی است.')
return
}
if (usageIds.value.includes(id) || seasonIds.value.includes(id)) {
toast.error('این شناسه از قبل وجود دارد.')
return
}
usage.value.push({
id,
title,
subtitle: newUsage.subtitle.trim() || 'مشاهده محصولات',
icon: newUsage.icon,
showTitle: newUsage.showTitle,
showSubtitle: newUsage.showSubtitle,
showIcon: newUsage.showIcon,
})
images[id] = null
newUsage.id = ''
newUsage.title = ''
newUsage.subtitle = 'مشاهده محصولات'
newUsage.icon = 'grid'
newUsage.showTitle = true
newUsage.showSubtitle = true
newUsage.showIcon = true
showAddUsage.value = false
}
function addSeason() {
const id = slugify(newSeason.id || newSeason.title)
const title = newSeason.title.trim()
if (!id || !title) {
toast.error('شناسه و عنوان دسته فصل الزامی است.')
return
}
if (usageIds.value.includes(id) || seasonIds.value.includes(id)) {
toast.error('این شناسه از قبل وجود دارد.')
return
}
seasons.value.push({
id,
title,
description: newSeason.description.trim(),
ctaLabel: newSeason.ctaLabel.trim() || 'مشاهده محصولات',
icon: newSeason.icon,
showTitle: newSeason.showTitle,
showDescription: newSeason.showDescription,
showCta: newSeason.showCta,
showIcon: newSeason.showIcon,
})
images[id] = null
newSeason.id = ''
newSeason.title = ''
newSeason.description = ''
newSeason.ctaLabel = 'مشاهده محصولات'
newSeason.icon = 'diamond'
newSeason.showTitle = true
newSeason.showDescription = true
newSeason.showCta = true
newSeason.showIcon = true
showAddSeason.value = false
}
function removeUsage(id: string) {
if (usage.value.length <= 1) {
toast.error('حداقل یک دسته کاربرد لازم است.')
return
}
usage.value = usage.value.filter((item) => item.id !== id)
delete images[id]
}
function removeSeason(id: string) {
if (seasons.value.length <= 1) {
toast.error('حداقل یک دسته فصل لازم است.')
return
}
seasons.value = seasons.value.filter((item) => item.id !== id)
delete images[id]
}
async function save() {
saving.value = true
try {
@@ -53,10 +371,17 @@ async function save() {
for (const [key, value] of Object.entries(images)) {
if (value) categoryImagesJson[key] = value
}
await settingsApi.update({ categoryImagesJson })
toast.success('تصاویر دسته‌بندی ذخیره شد.')
await settingsApi.update({
categoryDefsJson: {
usage: usage.value,
seasons: seasons.value,
},
categoryImagesJson,
})
toast.success('دسته‌بندی‌ها ذخیره شد.')
await load()
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'ذخیره تصاویر ناموفق بود.')
toast.error(err instanceof ApiError ? err.message : 'ذخیره دسته‌بندی‌ها ناموفق بود.')
} finally {
saving.value = false
}
@@ -72,37 +397,156 @@ onMounted(() => {
<div class="admin-page-header">
<div>
<h1 class="admin-page-header__title">دسته‌بندی‌ها</h1>
<p class="admin-page-header__sub">تصویر هر دسته در اپ کاربر نمایش داده می‌شود.</p>
<p class="admin-page-header__sub">
دسته‌های کاربرد و فصل را اضافه کنید. با کلید «نمایش / مخفی» مشخص کنید عنوان و توضیح در اپ کاربر دیده شود.
</p>
</div>
<button type="button" class="admin-btn admin-btn--primary" :disabled="saving || loading" @click="save">
{{ saving ? 'در حال ذخیره...' : 'ذخیره تصاویر' }}
{{ saving ? 'در حال ذخیره...' : 'ذخیره تغییرات' }}
</button>
</div>
<AdminPageSkeleton v-if="loading" variant="cards" />
<div v-else class="categories-admin__grid">
<section class="admin-card">
<h2>بر اساس کاربرد</h2>
<div class="categories-admin__list">
<AdminImagePicker
v-for="item in USAGE"
:key="item.id"
:label="item.title"
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
<section class="admin-card categories-panel">
<div class="categories-panel__head">
<div>
<h2>بر اساس کاربرد</h2>
<p>{{ usage.length }} دسته برای فیلتر محصول در اپ کاربر</p>
</div>
<button type="button" class="admin-btn admin-btn--outline" @click="showAddUsage = !showAddUsage">
{{ showAddUsage ? 'بستن فرم' : '+ افزودن دسته' }}
</button>
</div>
<div v-if="showAddUsage" class="categories-panel__add">
<input v-model="newUsage.title" class="admin-input" type="text" placeholder="عنوان (مثلاً پارچه‌های مانتویی)" />
<input
v-model="newUsage.id"
class="admin-input"
type="text"
dir="ltr"
placeholder="شناسه انگلیسی (اختیاری، مثلاً mantle)"
/>
<input v-model="newUsage.subtitle" class="admin-input" type="text" placeholder="زیرعنوان" />
<button type="button" class="admin-btn admin-btn--primary" @click="addUsage">ثبت در لیست</button>
</div>
<p v-if="!usage.length" class="categories-panel__empty">هنوز دسته‌ای ثبت نشده. با «افزودن دسته» شروع کنید.</p>
<div v-else class="categories-panel__list">
<article v-for="item in usage" :key="item.id" class="category-row">
<AdminImagePicker
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
/>
<div class="category-row__fields">
<div class="category-row__line">
<input v-model="item.title" class="admin-input" type="text" placeholder="عنوان دسته" />
<BaseSwitch
v-model="item.showTitle"
active-text="نمایش"
inactive-text="مخفی"
/>
</div>
<div class="category-row__line">
<input v-model="item.subtitle" class="admin-input" type="text" placeholder="زیرعنوان" />
<BaseSwitch
v-model="item.showSubtitle"
active-text="نمایش"
inactive-text="مخفی"
/>
</div>
<div class="category-row__line">
<select v-model="item.icon" class="admin-input" aria-label="آیکون">
<option v-for="opt in ICON_OPTIONS" :key="opt.value" :value="opt.value">
آیکون: {{ opt.label }}
</option>
</select>
<BaseSwitch
v-model="item.showIcon"
active-text="نمایش"
inactive-text="مخفی"
/>
</div>
<p class="category-row__id" dir="ltr">{{ item.id }}</p>
</div>
<button type="button" class="category-row__remove" @click="removeUsage(item.id)">حذف</button>
</article>
</div>
</section>
<section class="admin-card">
<h2>بر اساس فصل</h2>
<div class="categories-admin__list">
<AdminImagePicker
v-for="item in SEASONS"
:key="item.id"
:label="item.title"
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
<section class="admin-card categories-panel">
<div class="categories-panel__head">
<div>
<h2>بر اساس فصل</h2>
<p>{{ seasons.length }} دسته فصلی برای فیلتر محصول</p>
</div>
<button type="button" class="admin-btn admin-btn--outline" @click="showAddSeason = !showAddSeason">
{{ showAddSeason ? 'بستن فرم' : '+ افزودن دسته' }}
</button>
</div>
<div v-if="showAddSeason" class="categories-panel__add">
<input v-model="newSeason.title" class="admin-input" type="text" placeholder="عنوان (مثلاً بهاره)" />
<input
v-model="newSeason.id"
class="admin-input"
type="text"
dir="ltr"
placeholder="شناسه انگلیسی (اختیاری، مثلاً spring)"
/>
<input v-model="newSeason.description" class="admin-input" type="text" placeholder="توضیح" />
<button type="button" class="admin-btn admin-btn--primary" @click="addSeason">ثبت در لیست</button>
</div>
<p v-if="!seasons.length" class="categories-panel__empty">هنوز دسته‌ای ثبت نشده. با «افزودن دسته» شروع کنید.</p>
<div v-else class="categories-panel__list">
<article v-for="item in seasons" :key="item.id" class="category-row">
<AdminImagePicker
:model-value="images[item.id]"
@update:model-value="images[item.id] = $event"
/>
<div class="category-row__fields">
<div class="category-row__line">
<input v-model="item.title" class="admin-input" type="text" placeholder="عنوان دسته" />
<BaseSwitch
v-model="item.showTitle"
active-text="نمایش"
inactive-text="مخفی"
/>
</div>
<div class="category-row__line">
<input v-model="item.description" class="admin-input" type="text" placeholder="توضیح" />
<BaseSwitch
v-model="item.showDescription"
active-text="نمایش"
inactive-text="مخفی"
/>
</div>
<div class="category-row__line">
<input v-model="item.ctaLabel" class="admin-input" type="text" placeholder="متن لینک (مشاهده محصولات)" />
<BaseSwitch
v-model="item.showCta"
active-text="نمایش"
inactive-text="مخفی"
/>
</div>
<div class="category-row__line">
<select v-model="item.icon" class="admin-input" aria-label="آیکون">
<option v-for="opt in ICON_OPTIONS" :key="opt.value" :value="opt.value">
آیکون: {{ opt.label }}
</option>
</select>
<BaseSwitch
v-model="item.showIcon"
active-text="نمایش"
inactive-text="مخفی"
/>
</div>
<p class="category-row__id" dir="ltr">{{ item.id }}</p>
</div>
<button type="button" class="category-row__remove" @click="removeSeason(item.id)">حذف</button>
</article>
</div>
</section>
</div>
@@ -119,16 +563,105 @@ onMounted(() => {
.categories-admin__grid {
display: grid;
gap: 16px;
align-items: start;
}
.categories-admin__grid h2 {
margin: 0 0 14px;
font-size: 1rem;
.categories-panel {
padding: 20px;
}
.categories-admin__list {
.categories-panel__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--color-border);
}
.categories-panel__head h2 {
margin: 0 0 4px;
font-size: 1.05rem;
font-weight: 700;
}
.categories-panel__head p {
margin: 0;
font-size: 0.78rem;
color: var(--color-text-muted);
}
.categories-panel__add {
display: grid;
gap: 16px;
gap: 8px;
margin-bottom: 16px;
padding: 14px;
border: 1px dashed var(--color-border);
border-radius: 12px;
background: var(--color-bg);
}
.categories-panel__empty {
margin: 0;
padding: 28px 12px;
text-align: center;
color: var(--color-text-muted);
font-size: 0.875rem;
background: var(--color-bg);
border-radius: 12px;
}
.categories-panel__list {
display: grid;
gap: 12px;
}
.category-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
padding: 12px;
border: 1px solid var(--color-border);
border-radius: 14px;
background: var(--color-bg);
}
.category-row__fields {
display: grid;
gap: 8px;
min-width: 0;
}
.category-row__line {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.category-row__id {
margin: 0;
color: var(--color-text-muted);
font-size: 0.72rem;
font-family: ui-monospace, monospace;
}
.category-row__remove {
border: none;
background: transparent;
color: var(--color-danger, #dc3545);
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
padding: 6px 4px;
white-space: nowrap;
}
.category-row :deep(.image-picker__preview) {
width: 72px;
height: 60px;
}
@media (min-width: 900px) {
@@ -136,4 +669,14 @@ onMounted(() => {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 640px) {
.category-row {
grid-template-columns: 1fr;
}
.category-row__remove {
justify-self: start;
}
}
</style>
+133 -31
View File
@@ -2,12 +2,13 @@
import { computed, onMounted, reactive, ref } from 'vue'
import AdminPageSkeleton from '@/components/ui/AdminPageSkeleton.vue'
import BaseModal from '@/components/ui/BaseModal.vue'
import AdminImagePicker from '@/components/ui/AdminImagePicker.vue'
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
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 } from '@/services/api'
import { catalogsApi, productsApi, settingsApi } from '@/services/api'
import { ApiError } from '@/services/http'
import { useToast } from '@/composables/useToast'
import { formatCurrency, formatNumber } from '@/utils/format'
@@ -17,6 +18,17 @@ import { fileToCatalogImage } from '@/utils/catalogImage'
const productList = ref<Product[]>([])
const catalogs = ref<Catalog[]>([])
const usageOptions = ref<Array<{ value: string; label: string }>>([
{ value: 'trousers', label: 'شلوار' },
{ value: 'suit', label: 'کت‌وشلوار' },
{ value: 'shirt', label: 'پیراهن' },
{ value: 'other', label: 'سایر' },
])
const seasonOptions = ref<Array<{ value: string; label: string }>>([
{ value: 'summer', label: 'تابستان' },
{ value: 'winter', label: 'زمستان' },
{ value: 'all_season', label: 'چهارفصل' },
])
const searchQuery = ref('')
const catalogFilter = ref('all')
const loading = ref(true)
@@ -58,8 +70,8 @@ const emptyForm = () => ({
subtitle: '',
rollMeters: 50,
isNew: false,
colorsText: '',
imageUrls: [] as string[],
colorRows: [{ name: '', imageUrl: '' }] as Array<{ name: string; imageUrl: string }>,
extraImageUrls: [] as string[],
})
const form = reactive(emptyForm())
@@ -92,12 +104,28 @@ const filteredProducts = computed(() => {
async function loadData() {
loading.value = true
try {
const [productsData, catalogsData] = await Promise.all([
const [productsData, catalogsData, settings] = await Promise.all([
productsApi.list(),
catalogsApi.list(),
settingsApi.get().catch(() => null),
])
productList.value = productsData
catalogs.value = catalogsData
if (settings?.categoryDefsJson?.usage?.length) {
usageOptions.value = [
...settings.categoryDefsJson.usage.map((item) => ({
value: item.id,
label: item.title,
})),
{ value: 'other', label: 'سایر' },
]
}
if (settings?.categoryDefsJson?.seasons?.length) {
seasonOptions.value = settings.categoryDefsJson.seasons.map((item) => ({
value: item.id,
label: item.title,
}))
}
} catch (err) {
toast.error(err instanceof ApiError ? err.message : 'بارگذاری محصولات انجام نشد.')
} finally {
@@ -139,13 +167,33 @@ function openEditModal(product: Product) {
form.subtitle = (product as { subtitle?: string }).subtitle ?? ''
form.rollMeters = (product as { rollMeters?: number }).rollMeters ?? 50
form.isNew = (product as { isNew?: boolean }).isNew ?? false
form.colorsText = (product.colors ?? []).join(', ')
form.imageUrls = [...(product.imageUrls ?? [])]
const colors = product.colors ?? []
const colorImages = product.colorImages ?? []
const gallery = [...(product.imageUrls ?? [])]
if (colors.length) {
form.colorRows = colors.map((name, index) => ({
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))
} else {
form.colorRows = [{ name: product.color || '', imageUrl: gallery[0] || '' }]
form.extraImageUrls = gallery.slice(1)
}
formError.value = ''
}
function parseListText(value: string): string[] {
return value.split(',').map((part) => part.trim()).filter(Boolean)
function addColorRow() {
form.colorRows.push({ name: '', imageUrl: '' })
}
function removeColorRow(index: number) {
if (form.colorRows.length <= 1) {
form.colorRows[0] = { name: '', imageUrl: '' }
return
}
form.colorRows.splice(index, 1)
}
const imageBusy = ref(false)
@@ -159,7 +207,7 @@ async function addProductImages(event: Event) {
formError.value = ''
try {
const next = await Promise.all(files.map((file) => fileToCatalogImage(file)))
form.imageUrls = [...form.imageUrls, ...next]
form.extraImageUrls = [...form.extraImageUrls, ...next]
} catch (e) {
formError.value = e instanceof Error ? e.message : 'بارگذاری تصویر ناموفق بود.'
} finally {
@@ -168,7 +216,7 @@ async function addProductImages(event: Event) {
}
function removeProductImage(index: number) {
form.imageUrls.splice(index, 1)
form.extraImageUrls.splice(index, 1)
}
function closeModal() {
@@ -189,6 +237,12 @@ async function submitProduct() {
return
}
const colorEntries = form.colorRows
.map((row) => ({
name: row.name.trim(),
imageUrl: row.imageUrl.trim() || undefined,
}))
.filter((row) => row.name)
const catalogPayload = {
usageCategory: form.usageCategory,
season: form.season,
@@ -197,8 +251,8 @@ async function submitProduct() {
subtitle: form.subtitle.trim() || null,
rollMeters: form.rollMeters,
isNew: form.isNew,
colorsJson: parseListText(form.colorsText),
imageUrlsJson: form.imageUrls,
colorsJson: colorEntries,
imageUrlsJson: form.extraImageUrls,
}
const productCode = form.productCode.trim() || `${cat.code}-NEW`
saving.value = true
@@ -207,7 +261,7 @@ async function submitProduct() {
const updated = await productsApi.update(editingId.value, {
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
color: form.color.trim() || colorEntries[0]?.name || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
@@ -225,7 +279,7 @@ async function submitProduct() {
companyId: cat.companyId,
catalogId: form.catalogId,
name: form.name.trim(),
color: form.color.trim() || '—',
color: form.color.trim() || colorEntries[0]?.name || '—',
code: productCode,
productCode,
minMeterage: form.minMeterage,
@@ -445,18 +499,17 @@ function clearFilters() {
<div class="admin-form__field">
<label for="product-usage">کاربرد</label>
<select id="product-usage" v-model="form.usageCategory" class="admin-input">
<option value="trousers">شلوار</option>
<option value="suit">کت‌وشلوار</option>
<option value="shirt">پیراهن</option>
<option value="other">سایر</option>
<option v-for="opt in usageOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div class="admin-form__field">
<label for="product-season">فصل</label>
<select id="product-season" v-model="form.season" class="admin-input">
<option value="summer">تابستان</option>
<option value="winter">زمستان</option>
<option value="all_season">چهارفصل</option>
<option v-for="opt in seasonOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
@@ -484,21 +537,37 @@ function clearFilters() {
</div>
<div class="admin-form__field">
<label for="product-colors">رنگ‌ها (با ویرگول)</label>
<input
id="product-colors"
v-model="form.colorsText"
type="text"
class="admin-input"
placeholder="مثلاً: سرمه‌ای، خاکستری، بژ"
/>
<label>رنگ‌ها و تصویر هر رنگ</label>
<p class="color-variants__hint">برای هر رنگ یک نام و یک عکس جدا بگذارید؛ در فروشگاه با انتخاب همان رنگ، همان عکس نمایش داده می‌شود.</p>
<div class="color-variants">
<div v-for="(row, index) in form.colorRows" :key="index" class="color-variant">
<AdminImagePicker
:model-value="row.imageUrl || null"
@update:model-value="row.imageUrl = $event ?? ''"
/>
<div class="color-variant__fields">
<input
v-model="row.name"
type="text"
class="admin-input"
:placeholder="`نام رنگ ${index + 1}`"
/>
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="removeColorRow(index)">
حذف این رنگ
</button>
</div>
</div>
<button type="button" class="admin-btn admin-btn--outline" @click="addColorRow">
+ افزودن رنگ
</button>
</div>
</div>
<div class="admin-form__field">
<label>تصاویر محصول</label>
<label>تصاویر عمومی محصول (اختیاری)</label>
<div class="product-images">
<div
v-for="(src, index) in form.imageUrls"
v-for="(src, index) in form.extraImageUrls"
:key="`${index}-${src.slice(0, 24)}`"
class="product-images__item"
>
@@ -580,6 +649,39 @@ function clearFilters() {
background: #eef1f0;
}
.color-variants {
display: flex;
flex-direction: column;
gap: 14px;
}
.color-variants__hint {
margin: 0 0 10px;
color: var(--color-text-muted);
font-size: 0.75rem;
line-height: 1.6;
}
.color-variant {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-bg, #f7f8f7);
}
.color-variant__fields {
flex: 1;
min-width: 180px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
.product-images {
display: flex;
flex-wrap: wrap;