dynamic banners of khanegi_theme

This commit is contained in:
2026-07-18 09:55:51 +02:00
parent 7cf11cd62e
commit 3b34249a67
10 changed files with 895 additions and 194 deletions
@@ -0,0 +1,339 @@
<template>
<section class="profile-banners">
<header class="profile-banners__header">
<div class="profile-banners__header-text">
<div class="profile-banners__title-row">
<h2 class="profile-banners__title">تصاویر تم اپ (لایت / دارک)</h2>
<AdminFieldHelp :text="settingsProfileBannersIntro" size="sm" />
</div>
<p class="profile-banners__subtitle">
هر بخش را باز کنید — صفحهٔ اصلی، محصولات، قلک‌ها و تنظیمات
</p>
</div>
<BaseButton type="primary" :loading="saving" @click="handleSubmit">
ذخیره تصاویر تم
</BaseButton>
</header>
<a-collapse
v-model:activeKey="activeKey"
:bordered="false"
class="profile-banners__collapse"
>
<a-collapse-panel v-for="group in groups" :key="group.id">
<template #header>
<div class="profile-banners__collapse-header">
<BaseIcon :icon="group.icon" size="18" class="profile-banners__collapse-icon" />
<div class="profile-banners__collapse-text">
<span class="profile-banners__collapse-title">{{ group.title }}</span>
<span class="profile-banners__collapse-desc">{{ group.description }}</span>
</div>
<a-badge
:count="group.slots.length"
:number-style="{ backgroundColor: '#d4af37', color: '#fff', fontSize: '11px' }"
/>
</div>
</template>
<div class="profile-banners__list">
<article v-for="slot in group.slots" :key="slot.key" class="profile-banner-card">
<header class="profile-banner-card__head">
<div>
<div class="profile-banner-card__title">{{ slot.title }}</div>
<p class="profile-banner-card__desc">{{ slot.description }}</p>
</div>
</header>
<div class="profile-banner-card__themes">
<div class="profile-banner-card__theme">
<AdminLabelWithHelp label="تم روشن" :help="settingsProfileBannersThemeHelp" />
<div class="profile-banner-card__upload">
<BannerImageUpload
:image-url="state[slot.key].light"
@upload="state[slot.key].light = $event"
@remove="state[slot.key].light = null"
/>
</div>
</div>
<div class="profile-banner-card__theme">
<AdminLabelWithHelp label="تم تیره" :help="settingsProfileBannersThemeHelp" />
<div class="profile-banner-card__upload profile-banner-card__upload--dark">
<BannerImageUpload
:image-url="state[slot.key].dark"
@upload="state[slot.key].dark = $event"
@remove="state[slot.key].dark = null"
/>
</div>
</div>
</div>
</article>
</div>
</a-collapse-panel>
</a-collapse>
</section>
</template>
<script setup lang="ts">
import type { AppThemeBannerKey } from '@/model/appBanners'
import { useAppBannersAdmin } from '@/composables/useAppBannersAdmin'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import {
settingsProfileBannersIntro,
settingsProfileBannersThemeHelp,
} from '@/utils/adminHelpCopy'
import BannerImageUpload from './BannerImageUpload.vue'
const { state, saving, save } = useAppBannersAdmin()
/** پیش‌فرض همه کالپس‌ها بسته — فقط هدرها دیده می‌شوند */
const activeKey = ref<(string | number)[]>([])
type BannerSlot = {
key: AppThemeBannerKey
title: string
description: string
}
const groups: Array<{
id: string
title: string
description: string
icon: string
slots: BannerSlot[]
}> = [
{
id: 'home',
title: 'صفحهٔ اصلی',
description: 'کارت ارزش کل دارایی',
icon: 'vuesax-linear:home-2',
slots: [
{
key: 'homeAsset',
title: 'بنر کارت دارایی',
description: 'پس‌زمینهٔ کارت «ارزش کل دارایی» بالای صفحهٔ اصلی',
},
],
},
{
id: 'products',
title: 'محصولات (موبایل)',
description: 'اسلایدر بنرهای بالای صفحهٔ محصولات',
icon: 'vuesax-linear:box',
slots: [
{
key: 'productsHero1',
title: 'بنر محصولات ۱',
description: 'اولین اسلاید اسلایدر',
},
{
key: 'productsHero2',
title: 'بنر محصولات ۲',
description: 'دومین اسلاید اسلایدر',
},
{
key: 'productsHero3',
title: 'بنر محصولات ۳',
description: 'سومین اسلاید اسلایدر',
},
],
},
{
id: 'fund',
title: 'قلک‌های من',
description: 'تصویر قلک در صفحهٔ ساخت قلک',
icon: 'vuesax-linear:wallet-3',
slots: [
{
key: 'piggyCreate',
title: 'تصویر قلک',
description: 'تصویر قلک طلایی کنار دکمهٔ «ساخت قلک جدید»',
},
],
},
{
id: 'settings',
title: 'تنظیمات (پروفایل)',
description: 'بنرهای کد معرف، اشتراک‌گذاری و دعوت',
icon: 'vuesax-linear:setting-2',
slots: [
{
key: 'referral',
title: 'بنر کد معرف',
description: 'کارت «کد معرف شما» در صفحهٔ تنظیمات',
},
{
key: 'share',
title: 'بنر اشتراک‌گذاری',
description: 'کارت لینک اختصاصی و دکمهٔ اشتراک‌گذاری',
},
{
key: 'invite',
title: 'بنر دعوت از دوستان',
description: 'کارت دعوت از دوستان و دریافت هدیه',
},
],
},
]
async function handleSubmit() {
await save('تصاویر تم اپ ذخیره شد')
}
</script>
<style scoped lang="less">
.profile-banners {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-banners__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.profile-banners__title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.profile-banners__title {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
color: var(--admin-text);
}
.profile-banners__subtitle {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: var(--admin-text-secondary);
}
.profile-banners__collapse {
background: transparent;
}
.profile-banners__collapse :deep(.ant-collapse-item) {
margin-bottom: 0.75rem;
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-bg-elevated);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.profile-banners__collapse :deep(.ant-collapse-item:last-child) {
margin-bottom: 0;
}
.profile-banners__collapse :deep(.ant-collapse-header) {
padding: 0.875rem 1rem !important;
align-items: center !important;
background: color-mix(in srgb, var(--admin-bg-muted) 70%, transparent);
color: var(--admin-text);
}
.profile-banners__collapse :deep(.ant-collapse-content) {
border-top: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
}
.profile-banners__collapse :deep(.ant-collapse-content-box) {
padding: 1rem !important;
}
.profile-banners__collapse-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding-inline-end: 0.5rem;
}
.profile-banners__collapse-icon {
flex-shrink: 0;
color: #d4af37;
}
.profile-banners__collapse-text {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.profile-banners__collapse-title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--admin-text);
}
.profile-banners__collapse-desc {
font-size: 0.75rem;
color: var(--admin-text-secondary);
}
.profile-banners__list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.profile-banner-card {
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-bg-elevated);
}
.profile-banner-card__head {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-bg-muted) 55%, transparent);
}
.profile-banner-card__title {
font-size: 0.875rem;
font-weight: 700;
color: var(--admin-text);
}
.profile-banner-card__desc {
margin: 0.2rem 0 0;
font-size: 0.75rem;
color: var(--admin-text-secondary);
}
.profile-banner-card__themes {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
@media (min-width: 900px) {
grid-template-columns: 1fr 1fr;
}
}
.profile-banner-card__theme {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
}
.profile-banner-card__upload {
aspect-ratio: 16 / 9;
min-height: 10rem;
&--dark :deep(.banner-image-upload__filled) {
background: #121212;
}
}
</style>
+200 -190
View File
@@ -1,123 +1,140 @@
<template>
<div class="banners-page">
<header class="banners-page__header">
<div class="banners-page__header-text">
<div class="banners-page__title-row">
<h1 class="banners-page__title">بنرهای اسلایدر</h1>
<AdminFieldHelp :text="bannersPageIntro" size="sm" />
</div>
<p class="banners-page__subtitle">تصاویر بالای صفحهٔ اصلی اپ مشتری — هر ردیف یک اسلاید</p>
</div>
<div class="banners-page__header-actions">
<BaseButton size="small" type="primary" @click="addRow">
<span>افزودن اسلاید</span>
<BaseIcon icon="vuesax-linear:add" size="17" />
</BaseButton>
<BaseButton type="primary" :loading="saving" @click="handleSubmit">
ذخیره تغییرات
</BaseButton>
</div>
</header>
<div class="banners-page__body">
<div v-if="!benefitsList.length" class="banners-page__empty">
<div class="banners-page__empty-inner">
<BaseIcon icon="vuesax-linear:image" size="48" class="banners-page__empty-icon" />
<p class="banners-page__empty-title">هنوز بنری اضافه نشده</p>
<p class="banners-page__empty-text">اولین اسلاید را بسازید تا در اپ مشتری نمایش داده شود</p>
<BaseButton type="primary" @click="addRow">
<span>افزودن اسلاید</span>
<BaseIcon icon="vuesax-linear:add" size="17" />
</BaseButton>
</div>
</div>
<div v-else class="banners-page__list">
<article
v-for="(item, index) in benefitsList"
:key="index"
class="banner-card"
:class="{ 'banner-card--hidden': !item.show }"
>
<header class="banner-card__head">
<div class="banner-card__head-start">
<span class="banner-card__index">{{ index + 1 }}</span>
<div>
<div class="banner-card__title">اسلاید {{ index + 1 }}</div>
<div class="banner-card__meta">
<span class="banner-card__pill" :class="{ 'banner-card__pill--off': !item.show }">
{{ item.show ? 'فعال در اپ' : 'مخفی' }}
</span>
<span class="banner-card__ratio">۱۶:۹</span>
</div>
<a-collapse
v-if="!showSettingsProfileBanners"
v-model:activeKey="sliderActiveKey"
:bordered="false"
class="banners-page__collapse"
>
<a-collapse-panel key="slider">
<template #header>
<div class="banners-page__collapse-header">
<BaseIcon icon="vuesax-linear:slider-horizontal-1" size="18" class="banners-page__collapse-icon" />
<div class="banners-page__collapse-text">
<span class="banners-page__collapse-title">بنرهای اسلایدر</span>
<span class="banners-page__collapse-desc">تصاویر بالای صفحهٔ اصلی اپ مشتری — هر ردیف یک اسلاید</span>
</div>
<AdminFieldHelp :text="bannersPageIntro" size="sm" />
<a-badge
:count="state.slider.length"
:number-style="{ backgroundColor: '#d4af37', color: '#fff', fontSize: '11px' }"
/>
</div>
</template>
<div class="banner-card__head-end">
<label class="banner-card__switch">
<span>نمایش</span>
<a-switch v-model:checked="item.show" size="small" />
<AdminFieldHelp :text="bannersShowHelp" />
</label>
<button type="button" class="banner-card__remove" title="حذف اسلاید" @click="removeRow(index)">
<BaseIcon icon="vuesax-linear:trash" size="18" />
</button>
</div>
</header>
<div class="banners-page__panel-actions">
<BaseButton size="small" type="primary" @click="addRow">
<span>افزودن اسلاید</span>
<BaseIcon icon="vuesax-linear:add" size="17" />
</BaseButton>
<BaseButton type="primary" :loading="saving" @click="handleSubmit">
ذخیره تغییرات
</BaseButton>
</div>
<div class="banner-card__content">
<div class="banner-card__preview-wrap">
<AdminLabelWithHelp label="پیش‌نمایش تصویر" :help="bannersImageSectionHelp" />
<div class="banner-card__preview">
<BannerImageUpload
:image-url="item.icon"
@upload="item.icon = $event"
@remove="item.icon = null"
/>
</div>
</div>
<div class="banner-card__fields" :class="{ 'banner-card__fields--stack': item.typeClick === 'modal' }">
<div class="banner-card__field">
<AdminLabelWithHelp label="بعد از کلیک مشتری" :help="clickEventHelp(item.typeClick)" />
<BasicSelect
v-model:value="item.typeClick"
:items="typeList"
value-name="lable"
key-name="value"
/>
</div>
<div v-if="item.typeClick === 'link'" class="banner-card__field banner-card__field--wide">
<AdminLabelWithHelp label="آدرس لینک" :help="bannersLinkHelp" />
<BasicInput v-model:value="item.link" placeholder="https://example.com" />
</div>
<div v-if="item.typeClick === 'modal'" class="banner-card__field banner-card__field--wide">
<AdminLabelWithHelp label="متن پیام" :help="bannersModalHelp" />
<a-textarea
:value="htmlToPlain(item.modalText)"
:rows="7"
class="banner-card__message"
placeholder="متن پیامی که مشتری در پنجرهٔ بازشو می‌بیند..."
@update:value="item.modalText = plainToHtml($event)"
/>
</div>
<div v-if="!state.slider.length" class="banners-page__empty">
<div class="banners-page__empty-inner">
<BaseIcon icon="vuesax-linear:image" size="48" class="banners-page__empty-icon" />
<p class="banners-page__empty-title">هنوز بنری اضافه نشده</p>
<p class="banners-page__empty-text">اولین اسلاید را بسازید تا در اپ مشتری نمایش داده شود</p>
<BaseButton type="primary" @click="addRow">
<span>افزودن اسلاید</span>
<BaseIcon icon="vuesax-linear:add" size="17" />
</BaseButton>
</div>
</div>
</article>
</div>
<div v-else class="banners-page__list">
<article
v-for="(item, index) in state.slider"
:key="index"
class="banner-card"
:class="{ 'banner-card--hidden': !item.show }"
>
<header class="banner-card__head">
<div class="banner-card__head-start">
<span class="banner-card__index">{{ index + 1 }}</span>
<div>
<div class="banner-card__title">اسلاید {{ index + 1 }}</div>
<div class="banner-card__meta">
<span class="banner-card__pill" :class="{ 'banner-card__pill--off': !item.show }">
{{ item.show ? 'فعال در اپ' : 'مخفی' }}
</span>
<span class="banner-card__ratio">۱۶:۹</span>
</div>
</div>
</div>
<div class="banner-card__head-end">
<label class="banner-card__switch">
<span>نمایش</span>
<a-switch v-model:checked="item.show" size="small" />
<AdminFieldHelp :text="bannersShowHelp" />
</label>
<button type="button" class="banner-card__remove" title="حذف اسلاید" @click="removeRow(index)">
<BaseIcon icon="vuesax-linear:trash" size="18" />
</button>
</div>
</header>
<div class="banner-card__content">
<div class="banner-card__preview-wrap">
<AdminLabelWithHelp label="پیش‌نمایش تصویر" :help="bannersImageSectionHelp" />
<div class="banner-card__preview">
<BannerImageUpload
:image-url="item.icon"
@upload="item.icon = $event"
@remove="item.icon = null"
/>
</div>
</div>
<div class="banner-card__fields" :class="{ 'banner-card__fields--stack': item.typeClick === 'modal' }">
<div class="banner-card__field">
<AdminLabelWithHelp label="بعد از کلیک مشتری" :help="clickEventHelp(item.typeClick)" />
<BasicSelect
v-model:value="item.typeClick"
:items="typeList"
value-name="lable"
key-name="value"
/>
</div>
<div v-if="item.typeClick === 'link'" class="banner-card__field banner-card__field--wide">
<AdminLabelWithHelp label="آدرس لینک" :help="bannersLinkHelp" />
<BasicInput v-model:value="item.link" placeholder="https://example.com" />
</div>
<div v-if="item.typeClick === 'modal'" class="banner-card__field banner-card__field--wide">
<AdminLabelWithHelp label="متن پیام" :help="bannersModalHelp" />
<a-textarea
:value="htmlToPlain(item.modalText)"
:rows="7"
class="banner-card__message"
placeholder="متن پیامی که مشتری در پنجرهٔ بازشو می‌بیند..."
@update:value="item.modalText = plainToHtml($event)"
/>
</div>
</div>
</div>
</article>
</div>
</a-collapse-panel>
</a-collapse>
<SettingsProfileBanners v-if="showSettingsProfileBanners" />
</div>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import type { IBenefits } from '@/model/banner'
import BannerImageUpload from './components/BannerImageUpload.vue'
import SettingsProfileBanners from './components/SettingsProfileBanners.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import { updateBannerConst, getHeaderBannerConst } from '@/api/consts'
import { useAppBannersAdmin } from '@/composables/useAppBannersAdmin'
import { hasAppUiConfig } from '@/utils/config'
import {
bannersEventHelp,
bannersImageSectionHelp,
@@ -129,9 +146,10 @@ import {
bannersShowHelp,
} from '@/utils/adminHelpCopy'
const benefitsList = ref<IBenefits[]>([])
const saving = ref(false)
let id: number | null = null
const { state, saving, save } = useAppBannersAdmin()
/** پیش‌فرض کالپس بنرهای اسلایدر بسته است */
const sliderActiveKey = ref<(string | number)[]>([])
const showSettingsProfileBanners = computed(() => hasAppUiConfig())
const typeList = ref([
{ lable: 'بدون رویداد', value: null },
@@ -167,21 +185,11 @@ function plainToHtml(text: string | null | undefined): string | null {
return `<p>${escaped.replace(/\n/g, '<br>')}</p>`
}
function parseBannerList(value: string | null | undefined): IBenefits[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function addRow() {
if (!Array.isArray(benefitsList.value)) {
benefitsList.value = []
if (!Array.isArray(state.slider)) {
state.slider = []
}
benefitsList.value.push({
state.slider.push({
icon: null,
show: true,
caption: null,
@@ -192,43 +200,12 @@ function addRow() {
}
function removeRow(index: number) {
benefitsList.value.splice(index, 1)
state.slider.splice(index, 1)
}
async function handleSubmit() {
if (!id) {
message.error('شناسه بنر یافت نشد؛ صفحه را رفرش کنید')
return
}
try {
saving.value = true
await updateBannerConst({
id,
value: JSON.stringify(benefitsList.value),
key: 'header_banner',
})
message.success('اطلاعات با موفقیت ذخیره شد')
} catch (error) {
message.error('خطا در ذخیره اطلاعات')
} finally {
saving.value = false
}
await save('اطلاعات با موفقیت ذخیره شد')
}
onMounted(async () => {
try {
const res = await getHeaderBannerConst()
if (!res) {
return
}
id = res.id ?? res.const_id ?? null
benefitsList.value = parseBannerList(res.value)
} catch (error) {
console.error('خطا در دریافت بنرها:', error)
}
})
</script>
<style scoped lang="less">
@@ -241,43 +218,6 @@ onMounted(async () => {
min-height: 0;
}
.banners-page__header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
}
.banners-page__title-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.banners-page__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--admin-text);
}
.banners-page__subtitle {
margin: 0.35rem 0 0;
font-size: 0.8125rem;
color: var(--admin-text-secondary);
}
.banners-page__header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.625rem;
}
.banners-page__body {
flex: 1;
min-height: 0;
@@ -285,10 +225,80 @@ onMounted(async () => {
padding: 1rem 1.25rem 1.5rem;
}
.banners-page__collapse {
background: transparent;
}
.banners-page__collapse :deep(.ant-collapse-item) {
margin-bottom: 0;
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: var(--admin-bg-elevated);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.banners-page__collapse :deep(.ant-collapse-header) {
padding: 0.875rem 1rem !important;
align-items: center !important;
background: color-mix(in srgb, var(--admin-bg-muted) 70%, transparent);
color: var(--admin-text);
}
.banners-page__collapse :deep(.ant-collapse-content) {
border-top: 1px solid var(--admin-border);
background: var(--admin-bg-elevated);
}
.banners-page__collapse :deep(.ant-collapse-content-box) {
padding: 1rem !important;
}
.banners-page__collapse-header {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding-inline-end: 0.5rem;
}
.banners-page__collapse-icon {
flex-shrink: 0;
color: #d4af37;
}
.banners-page__collapse-text {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
gap: 0.15rem;
}
.banners-page__collapse-title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--admin-text);
}
.banners-page__collapse-desc {
font-size: 0.75rem;
color: var(--admin-text-secondary);
}
.banners-page__panel-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.625rem;
margin-bottom: 1rem;
}
.banners-page__empty {
display: flex;
width: 100%;
min-height: calc(100vh - 12rem);
min-height: 14rem;
align-items: center;
justify-content: center;
}