change UI & fix bugs
This commit is contained in:
+29
-1
@@ -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 = {
|
||||
|
||||
@@ -233,6 +233,7 @@ export interface Product {
|
||||
rollMeters?: number
|
||||
isNew?: boolean
|
||||
colors?: string[]
|
||||
colorImages?: Array<string | null>
|
||||
imageUrls?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user