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
-3
View File
@@ -10,7 +10,6 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
_index: typeof import('./src/core/components/_sample/_index.vue')['default'] _index: typeof import('./src/core/components/_sample/_index.vue')['default']
AAlert: typeof import('ant-design-vue/es')['Alert']
AAvatar: typeof import('ant-design-vue/es')['Avatar'] AAvatar: typeof import('ant-design-vue/es')['Avatar']
ABadge: typeof import('ant-design-vue/es')['Badge'] ABadge: typeof import('ant-design-vue/es')['Badge']
AButton: typeof import('ant-design-vue/es')['Button'] AButton: typeof import('ant-design-vue/es')['Button']
@@ -43,7 +42,6 @@ declare module '@vue/runtime-core' {
APagination: typeof import('ant-design-vue/es')['Pagination'] APagination: typeof import('ant-design-vue/es')['Pagination']
APopconfirm: typeof import('ant-design-vue/es')['Popconfirm'] APopconfirm: typeof import('ant-design-vue/es')['Popconfirm']
APopover: typeof import('ant-design-vue/es')['Popover'] APopover: typeof import('ant-design-vue/es')['Popover']
ARadio: typeof import('ant-design-vue/es')['Radio']
ARadioButton: typeof import('ant-design-vue/es')['RadioButton'] ARadioButton: typeof import('ant-design-vue/es')['RadioButton']
ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup'] ARadioGroup: typeof import('ant-design-vue/es')['RadioGroup']
ARow: typeof import('ant-design-vue/es')['Row'] ARow: typeof import('ant-design-vue/es')['Row']
@@ -55,7 +53,6 @@ declare module '@vue/runtime-core' {
ATable: typeof import('ant-design-vue/es')['Table'] ATable: typeof import('ant-design-vue/es')['Table']
ATabPane: typeof import('ant-design-vue/es')['TabPane'] ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs'] ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag']
ATextarea: typeof import('ant-design-vue/es')['Textarea'] ATextarea: typeof import('ant-design-vue/es')['Textarea']
ATimePicker: typeof import('ant-design-vue/es')['TimePicker'] ATimePicker: typeof import('ant-design-vue/es')['TimePicker']
ATooltip: typeof import('ant-design-vue/es')['Tooltip'] ATooltip: typeof import('ant-design-vue/es')['Tooltip']
+116
View File
@@ -0,0 +1,116 @@
import useAxios from '@core/utils/useAxios'
import type { Const } from '@/model/const'
import type { IBenefits } from '@/model/banner'
import {
APP_BANNERS_CONST_KEY,
LEGACY_SLIDER_BANNERS_CONST_KEY,
LEGACY_THEME_BANNERS_CONST_KEY,
emptyAppBanners,
parseAppBanners,
serializeAppBanners,
type AppBannersValue,
} from '@/model/appBanners'
function pickConstRow(res: Const | Const[] | null | undefined): Const | null {
if (!res) return null
if (Array.isArray(res)) return (res[0] as Const) ?? null
return res
}
function parseLegacySlider(value: string | null | undefined): IBenefits[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? (parsed as IBenefits[]) : []
} catch {
return []
}
}
export type AppBannersConstState = {
/** فقط وقتی رکورد واقعاً با key=app_banners است؛ وگرنه null تا create شود */
id: number | null
data: AppBannersValue
}
/**
* یک API واحد برای همهٔ بنرها.
* اگر `app_banners` نبود، از کلیدهای قدیمی migrate می‌کند (فقط خواندن).
*/
export async function loadAppBannersConst(): Promise<AppBannersConstState> {
try {
const res = await useAxios.get<Const | Const[]>(`/api/const/key/${APP_BANNERS_CONST_KEY}`)
const row = pickConstRow(res)
if (row) {
return {
id: row.id ?? row.const_id ?? null,
data: parseAppBanners(row.value),
}
}
} catch {
// ادامه با مهاجرت از کلیدهای قدیمی
}
const merged = emptyAppBanners()
try {
const themeRes = await useAxios.get<Const | Const[]>(
`/api/const/key/${LEGACY_THEME_BANNERS_CONST_KEY}`
)
const themeRow = pickConstRow(themeRes)
if (themeRow) {
const themes = parseAppBanners(themeRow.value)
Object.assign(merged, {
homeAsset: themes.homeAsset,
piggyCreate: themes.piggyCreate,
productsHero1: themes.productsHero1,
productsHero2: themes.productsHero2,
productsHero3: themes.productsHero3,
referral: themes.referral,
share: themes.share,
invite: themes.invite,
slider: themes.slider?.length ? themes.slider : merged.slider,
})
}
} catch {
// ignore
}
try {
const sliderRes = await useAxios.get<Const | Const[]>(
`/api/const/key/${LEGACY_SLIDER_BANNERS_CONST_KEY}`
)
const sliderRow = pickConstRow(sliderRes)
if (sliderRow) {
const slides = parseLegacySlider(sliderRow.value)
if (slides.length) merged.slider = slides
}
} catch {
// ignore
}
// id=null → اولین ذخیره، رکورد جدید با key=app_banners می‌سازد
return { id: null, data: merged }
}
export async function saveAppBannersConst(
id: number | null,
data: AppBannersValue
): Promise<number | null> {
const value = serializeAppBanners(data)
const payload = {
key: APP_BANNERS_CONST_KEY,
value,
title_fa: 'بنرهای اپ',
is_active: true,
description: 'همهٔ بنرهای اپ: اسلایدر صفحه اصلی + تصاویر تم لایت/دارک',
}
if (id) {
await useAxios.put<Const>(`/admin/const/${id}`, { id, ...payload })
return id
}
const created = await useAxios.post<Const>(`/admin/const`, { id: 0, ...payload })
return created?.id ?? created?.const_id ?? null
}
+75
View File
@@ -0,0 +1,75 @@
import { reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import { loadAppBannersConst, saveAppBannersConst } from '@/api/appBanners'
import { emptyAppBanners, type AppBannersValue } from '@/model/appBanners'
const state = reactive<AppBannersValue>(emptyAppBanners())
const constId = ref<number | null>(null)
const loaded = ref(false)
const loading = ref(false)
const saving = ref(false)
let loadPromise: Promise<void> | null = null
async function load(force = false) {
if (!force && loaded.value) return
if (!force && loadPromise) return loadPromise
loadPromise = (async () => {
loading.value = true
try {
const res = await loadAppBannersConst()
constId.value = res.id
Object.assign(state, emptyAppBanners(), res.data)
loaded.value = true
} catch (error) {
console.error('خطا در دریافت بنرهای اپ:', error)
Object.assign(state, emptyAppBanners())
} finally {
loading.value = false
loadPromise = null
}
})()
return loadPromise
}
async function save(successText = 'بنرهای اپ ذخیره شد') {
saving.value = true
try {
const nextId = await saveAppBannersConst(constId.value, {
slider: [...state.slider],
homeAsset: { ...state.homeAsset },
piggyCreate: { ...state.piggyCreate },
productsHero1: { ...state.productsHero1 },
productsHero2: { ...state.productsHero2 },
productsHero3: { ...state.productsHero3 },
referral: { ...state.referral },
share: { ...state.share },
invite: { ...state.invite },
})
constId.value = nextId
loaded.value = true
message.success(successText)
return true
} catch (error) {
console.error(error)
message.error('خطا در ذخیره بنرهای اپ')
return false
} finally {
saving.value = false
}
}
export function useAppBannersAdmin() {
void load()
return {
state,
constId,
loaded,
loading,
saving,
load,
save,
}
}
+138
View File
@@ -0,0 +1,138 @@
import type { IBenefits } from '@/model/banner'
/** کلید واحد همهٔ بنرهای اپ (اسلایدر + تم لایت/دارک) */
export const APP_BANNERS_CONST_KEY = 'app_banners'
/** کلید قدیمی تم — فقط برای مهاجرت خواندن */
export const LEGACY_THEME_BANNERS_CONST_KEY = 'settings_profile_banners'
/** کلید قدیمی اسلایدر — فقط برای مهاجرت خواندن */
export const LEGACY_SLIDER_BANNERS_CONST_KEY = 'header_banner'
export type AppThemeBannerKey =
| 'homeAsset'
| 'piggyCreate'
| 'productsHero1'
| 'productsHero2'
| 'productsHero3'
| 'referral'
| 'share'
| 'invite'
/** @deprecated از AppThemeBannerKey استفاده کنید */
export type SettingsProfileBannerKey = AppThemeBannerKey
export interface ThemeImagePair {
light: string | null
dark: string | null
}
export interface AppBannersValue {
slider: IBenefits[]
homeAsset: ThemeImagePair
piggyCreate: ThemeImagePair
productsHero1: ThemeImagePair
productsHero2: ThemeImagePair
productsHero3: ThemeImagePair
referral: ThemeImagePair
share: ThemeImagePair
invite: ThemeImagePair
}
/** @deprecated از AppBannersValue استفاده کنید */
export type SettingsProfileBannersValue = Omit<AppBannersValue, 'slider'>
export const SETTINGS_PROFILE_BANNERS_CONST_KEY = APP_BANNERS_CONST_KEY
export function emptyThemePair(): ThemeImagePair {
return { light: null, dark: null }
}
export function emptyAppBanners(): AppBannersValue {
return {
slider: [],
homeAsset: emptyThemePair(),
piggyCreate: emptyThemePair(),
productsHero1: emptyThemePair(),
productsHero2: emptyThemePair(),
productsHero3: emptyThemePair(),
referral: emptyThemePair(),
share: emptyThemePair(),
invite: emptyThemePair(),
}
}
export function emptySettingsProfileBanners(): SettingsProfileBannersValue {
const { slider: _slider, ...themes } = emptyAppBanners()
return themes
}
function normalizePair(raw: unknown): ThemeImagePair {
if (!raw || typeof raw !== 'object') return emptyThemePair()
const obj = raw as Record<string, unknown>
return {
light: typeof obj.light === 'string' && obj.light.trim() ? obj.light : null,
dark: typeof obj.dark === 'string' && obj.dark.trim() ? obj.dark : null,
}
}
function parseSlider(raw: unknown): IBenefits[] {
if (!raw) return []
if (Array.isArray(raw)) return raw as IBenefits[]
if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as IBenefits[]) : []
} catch {
return []
}
}
return []
}
export function parseAppBanners(value: string | null | undefined): AppBannersValue {
const fallback = emptyAppBanners()
if (!value) return fallback
try {
const parsed = JSON.parse(value)
if (!parsed || typeof parsed !== 'object') return fallback
// فرمت قدیمی: فقط آرایهٔ اسلایدر (header_banner)
if (Array.isArray(parsed)) {
return { ...fallback, slider: parsed as IBenefits[] }
}
return {
slider: parseSlider(parsed.slider),
homeAsset: normalizePair(parsed.homeAsset),
piggyCreate: normalizePair(parsed.piggyCreate),
productsHero1: normalizePair(parsed.productsHero1),
productsHero2: normalizePair(parsed.productsHero2),
productsHero3: normalizePair(parsed.productsHero3),
referral: normalizePair(parsed.referral),
share: normalizePair(parsed.share),
invite: normalizePair(parsed.invite),
}
} catch {
return fallback
}
}
export function parseSettingsProfileBanners(value: string | null | undefined): SettingsProfileBannersValue {
const { slider: _slider, ...themes } = parseAppBanners(value)
return themes
}
export function serializeAppBanners(data: AppBannersValue): string {
return JSON.stringify({
slider: data.slider ?? [],
homeAsset: { ...data.homeAsset },
piggyCreate: { ...data.piggyCreate },
productsHero1: { ...data.productsHero1 },
productsHero2: { ...data.productsHero2 },
productsHero3: { ...data.productsHero3 },
referral: { ...data.referral },
share: { ...data.share },
invite: { ...data.invite },
})
}
+10
View File
@@ -0,0 +1,10 @@
/** سازگاری با importهای قبلی — منبع اصلی: appBanners.ts */
export {
APP_BANNERS_CONST_KEY as SETTINGS_PROFILE_BANNERS_CONST_KEY,
emptySettingsProfileBanners,
emptyThemePair,
parseSettingsProfileBanners,
type AppThemeBannerKey as SettingsProfileBannerKey,
type SettingsProfileBannersValue,
type ThemeImagePair,
} from '@/model/appBanners'
@@ -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> <template>
<div class="banners-page"> <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 class="banners-page__body">
<div v-if="!benefitsList.length" class="banners-page__empty"> <a-collapse
<div class="banners-page__empty-inner"> v-if="!showSettingsProfileBanners"
<BaseIcon icon="vuesax-linear:image" size="48" class="banners-page__empty-icon" /> v-model:activeKey="sliderActiveKey"
<p class="banners-page__empty-title">هنوز بنری اضافه نشده</p> :bordered="false"
<p class="banners-page__empty-text">اولین اسلاید را بسازید تا در اپ مشتری نمایش داده شود</p> class="banners-page__collapse"
<BaseButton type="primary" @click="addRow"> >
<span>افزودن اسلاید</span> <a-collapse-panel key="slider">
<BaseIcon icon="vuesax-linear:add" size="17" /> <template #header>
</BaseButton> <div class="banners-page__collapse-header">
</div> <BaseIcon icon="vuesax-linear:slider-horizontal-1" size="18" class="banners-page__collapse-icon" />
</div> <div class="banners-page__collapse-text">
<span class="banners-page__collapse-title">بنرهای اسلایدر</span>
<div v-else class="banners-page__list"> <span class="banners-page__collapse-desc">تصاویر بالای صفحهٔ اصلی اپ مشتری — هر ردیف یک اسلاید</span>
<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>
</div> </div>
<AdminFieldHelp :text="bannersPageIntro" size="sm" />
<a-badge
:count="state.slider.length"
:number-style="{ backgroundColor: '#d4af37', color: '#fff', fontSize: '11px' }"
/>
</div> </div>
</template>
<div class="banner-card__head-end"> <div class="banners-page__panel-actions">
<label class="banner-card__switch"> <BaseButton size="small" type="primary" @click="addRow">
<span>نمایش</span> <span>افزودن اسلاید</span>
<a-switch v-model:checked="item.show" size="small" /> <BaseIcon icon="vuesax-linear:add" size="17" />
<AdminFieldHelp :text="bannersShowHelp" /> </BaseButton>
</label> <BaseButton type="primary" :loading="saving" @click="handleSubmit">
<button type="button" class="banner-card__remove" title="حذف اسلاید" @click="removeRow(index)"> ذخیره تغییرات
<BaseIcon icon="vuesax-linear:trash" size="18" /> </BaseButton>
</button> </div>
</div>
</header>
<div class="banner-card__content"> <div v-if="!state.slider.length" class="banners-page__empty">
<div class="banner-card__preview-wrap"> <div class="banners-page__empty-inner">
<AdminLabelWithHelp label="پیش‌نمایش تصویر" :help="bannersImageSectionHelp" /> <BaseIcon icon="vuesax-linear:image" size="48" class="banners-page__empty-icon" />
<div class="banner-card__preview"> <p class="banners-page__empty-title">هنوز بنری اضافه نشده</p>
<BannerImageUpload <p class="banners-page__empty-text">اولین اسلاید را بسازید تا در اپ مشتری نمایش داده شود</p>
:image-url="item.icon" <BaseButton type="primary" @click="addRow">
@upload="item.icon = $event" <span>افزودن اسلاید</span>
@remove="item.icon = null" <BaseIcon icon="vuesax-linear:add" size="17" />
/> </BaseButton>
</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>
</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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue'
import type { IBenefits } from '@/model/banner'
import BannerImageUpload from './components/BannerImageUpload.vue' import BannerImageUpload from './components/BannerImageUpload.vue'
import SettingsProfileBanners from './components/SettingsProfileBanners.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue' import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue' import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import { updateBannerConst, getHeaderBannerConst } from '@/api/consts' import { useAppBannersAdmin } from '@/composables/useAppBannersAdmin'
import { hasAppUiConfig } from '@/utils/config'
import { import {
bannersEventHelp, bannersEventHelp,
bannersImageSectionHelp, bannersImageSectionHelp,
@@ -129,9 +146,10 @@ import {
bannersShowHelp, bannersShowHelp,
} from '@/utils/adminHelpCopy' } from '@/utils/adminHelpCopy'
const benefitsList = ref<IBenefits[]>([]) const { state, saving, save } = useAppBannersAdmin()
const saving = ref(false) /** پیش‌فرض کالپس بنرهای اسلایدر بسته است */
let id: number | null = null const sliderActiveKey = ref<(string | number)[]>([])
const showSettingsProfileBanners = computed(() => hasAppUiConfig())
const typeList = ref([ const typeList = ref([
{ lable: 'بدون رویداد', value: null }, { lable: 'بدون رویداد', value: null },
@@ -167,21 +185,11 @@ function plainToHtml(text: string | null | undefined): string | null {
return `<p>${escaped.replace(/\n/g, '<br>')}</p>` 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() { function addRow() {
if (!Array.isArray(benefitsList.value)) { if (!Array.isArray(state.slider)) {
benefitsList.value = [] state.slider = []
} }
benefitsList.value.push({ state.slider.push({
icon: null, icon: null,
show: true, show: true,
caption: null, caption: null,
@@ -192,43 +200,12 @@ function addRow() {
} }
function removeRow(index: number) { function removeRow(index: number) {
benefitsList.value.splice(index, 1) state.slider.splice(index, 1)
} }
async function handleSubmit() { async function handleSubmit() {
if (!id) { await save('اطلاعات با موفقیت ذخیره شد')
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
}
} }
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> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -241,43 +218,6 @@ onMounted(async () => {
min-height: 0; 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 { .banners-page__body {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -285,10 +225,80 @@ onMounted(async () => {
padding: 1rem 1.25rem 1.5rem; 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 { .banners-page__empty {
display: flex; display: flex;
width: 100%; width: 100%;
min-height: calc(100vh - 12rem); min-height: 14rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
+6
View File
@@ -32,6 +32,12 @@ export const bannersShareHelp =
export const bannersNoEventHelp = export const bannersNoEventHelp =
'فقط تصویر دیده می‌شود؛ کلیک هیچ کاری نمی‌کند. برای بنرهای تزئینی مناسب است.' 'فقط تصویر دیده می‌شود؛ کلیک هیچ کاری نمی‌کند. برای بنرهای تزئینی مناسب است.'
export const settingsProfileBannersIntro =
'تصاویر تم اپ: کارت دارایی صفحهٔ اصلی، تصویر قلک، اسلایدر بنرهای موبایل صفحهٔ محصولات، و بنرهای صفحهٔ تنظیمات. برای هر مورد دو تصویر جدا — تم روشن و تم تیره — آپلود کنید.'
export const settingsProfileBannersThemeHelp =
'تم روشن: وقتی مشتری حالت روشن را انتخاب کرده. تم تیره: وقتی حالت تیره فعال است. اگر فقط یکی را بگذارید، اپ برای دیگری از تصویر پیش‌فرض خودش استفاده می‌کند.'
export const pricingPageIntro = export const pricingPageIntro =
'اینجا تاریخچهٔ همگام‌سازی قیمت را می‌بینید. دکمهٔ «به‌روزرسانی قیمت» همان لحظه قیمت را از منبعی که در تنظیمات مشخص کرده‌اید می‌گیرد و روی محصولات اعمال می‌کند. اگر قیمت عوض نشده باشد پیام «تغییری نکرد» می‌آید.' 'اینجا تاریخچهٔ همگام‌سازی قیمت را می‌بینید. دکمهٔ «به‌روزرسانی قیمت» همان لحظه قیمت را از منبعی که در تنظیمات مشخص کرده‌اید می‌گیرد و روی محصولات اعمال می‌کند. اگر قیمت عوض نشده باشد پیام «تغییری نکرد» می‌آید.'
+10
View File
@@ -12,6 +12,16 @@ export function getConfigApp(key: string) {
return null return null
} }
/** true اگر ui_config یا app_ui_config در get_user مقدار داشته باشد */
export function hasAppUiConfig(): boolean {
const cfg = getConfigApp('ui_config') ?? getConfigApp('app_ui_config')
if (cfg == null) return false
if (typeof cfg === 'string') return cfg.trim().length > 0
if (Array.isArray(cfg)) return cfg.length > 0
if (typeof cfg === 'object') return Object.keys(cfg as object).length > 0
return Boolean(cfg)
}
let storeModeFromSettings: boolean | null = null let storeModeFromSettings: boolean | null = null
let settingsBootstrapped = false let settingsBootstrapped = false