Files
boursecu_admin/src/pages/menuPages/banners/index.vue
T

517 lines
13 KiB
Vue
Raw Normal View History

2026-02-21 20:24:59 +03:30
<template>
2026-07-06 19:06:57 +03:30
<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>
2026-02-21 20:24:59 +03:30
</div>
2026-07-06 19:06:57 +03:30
<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>
2026-07-06 18:30:21 +03:30
2026-07-06 19:06:57 +03:30
<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>
</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>
2026-02-21 20:24:59 +03:30
</div>
</div>
</template>
<script setup lang="ts">
2026-07-06 19:06:57 +03:30
import { message } from 'ant-design-vue'
2026-07-06 18:30:21 +03:30
import type { IBenefits } from '@/model/banner'
2026-07-06 19:06:57 +03:30
import BannerImageUpload from './components/BannerImageUpload.vue'
2026-07-06 18:30:21 +03:30
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
2026-07-06 19:06:57 +03:30
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
2026-07-06 18:30:21 +03:30
import { updateBannerConst, getHeaderBannerConst } from '@/api/consts'
import {
bannersEventHelp,
2026-07-06 19:06:57 +03:30
bannersImageSectionHelp,
2026-07-06 18:30:21 +03:30
bannersLinkHelp,
bannersModalHelp,
bannersNoEventHelp,
bannersPageIntro,
bannersShareHelp,
bannersShowHelp,
} from '@/utils/adminHelpCopy'
2026-02-21 20:24:59 +03:30
const benefitsList = ref<IBenefits[]>([])
2026-07-06 19:06:57 +03:30
const saving = ref(false)
2026-02-21 20:24:59 +03:30
let id: number | null = null
2026-07-06 19:06:57 +03:30
2026-05-02 13:53:43 +03:30
const typeList = ref([
2026-07-06 19:06:57 +03:30
{ lable: 'بدون رویداد', value: null },
2026-07-06 18:30:21 +03:30
{ lable: 'باز شدن لینک', value: 'link' },
{ lable: 'پنجره بازشو', value: 'modal' },
2026-07-06 19:06:57 +03:30
{ lable: 'اشتراک‌گذاری', value: 'share' },
2026-05-02 13:53:43 +03:30
])
2026-07-06 18:30:21 +03:30
2026-07-06 19:06:57 +03:30
function clickEventHelp(typeClick: string | null) {
if (typeClick === 'link') return `${bannersEventHelp}\n\n${bannersLinkHelp}`
if (typeClick === 'modal') return `${bannersEventHelp}\n\n${bannersModalHelp}`
if (typeClick === 'share') return `${bannersEventHelp}\n\n${bannersShareHelp}`
return `${bannersEventHelp}\n\n${bannersNoEventHelp}`
}
function htmlToPlain(html: string | null | undefined): string {
if (!html) return ''
if (typeof document === 'undefined') {
return html.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '').trim()
}
const el = document.createElement('div')
el.innerHTML = html
return (el.textContent ?? el.innerText ?? '').trim()
}
function plainToHtml(text: string | null | undefined): string | null {
const trimmed = (text ?? '').trim()
if (!trimmed) return null
const escaped = trimmed
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
return `<p>${escaped.replace(/\n/g, '<br>')}</p>`
}
2026-07-05 12:05:18 +02:00
function parseBannerList(value: string | null | undefined): IBenefits[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
2026-02-21 20:24:59 +03:30
function addRow() {
2026-07-05 12:05:18 +02:00
if (!Array.isArray(benefitsList.value)) {
benefitsList.value = []
}
2026-07-06 18:30:21 +03:30
benefitsList.value.push({
icon: null,
show: true,
caption: null,
link: null,
typeClick: null,
modalText: null,
})
2026-02-21 20:24:59 +03:30
}
2026-07-06 19:06:57 +03:30
function removeRow(index: number) {
benefitsList.value.splice(index, 1)
2026-02-21 20:24:59 +03:30
}
async function handleSubmit() {
2026-07-05 16:41:45 +03:30
if (!id) {
message.error('شناسه بنر یافت نشد؛ صفحه را رفرش کنید')
return
}
2026-02-21 20:24:59 +03:30
try {
2026-07-06 19:06:57 +03:30
saving.value = true
2026-02-21 20:24:59 +03:30
await updateBannerConst({
2026-07-05 16:41:45 +03:30
id,
2026-02-21 20:24:59 +03:30
value: JSON.stringify(benefitsList.value),
2026-07-05 16:41:45 +03:30
key: 'header_banner',
2026-02-21 20:24:59 +03:30
})
2026-07-05 16:41:45 +03:30
message.success('اطلاعات با موفقیت ذخیره شد')
2026-02-21 20:24:59 +03:30
} catch (error) {
2026-07-05 16:41:45 +03:30
message.error('خطا در ذخیره اطلاعات')
2026-07-06 19:06:57 +03:30
} finally {
saving.value = false
2026-02-21 20:24:59 +03:30
}
}
onMounted(async () => {
try {
const res = await getHeaderBannerConst()
2026-07-05 16:41:45 +03:30
if (!res) {
return
2026-02-21 20:24:59 +03:30
}
2026-07-05 16:41:45 +03:30
id = res.id ?? res.const_id ?? null
benefitsList.value = parseBannerList(res.value)
2026-02-21 20:24:59 +03:30
} catch (error) {
console.error('خطا در دریافت بنرها:', error)
}
})
</script>
2026-07-06 19:06:57 +03:30
<style scoped lang="less">
.banners-page {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
2026-05-02 13:53:43 +03:30
}
2026-07-06 19:06:57 +03:30
.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;
2026-05-02 13:53:43 +03:30
overflow-y: auto;
2026-07-06 19:06:57 +03:30
padding: 1rem 1.25rem 1.5rem;
}
.banners-page__empty {
display: flex;
width: 100%;
min-height: calc(100vh - 12rem);
align-items: center;
justify-content: center;
}
.banners-page__empty-inner {
display: flex;
width: 100%;
max-width: 28rem;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 3rem 1.5rem;
border: 1px dashed var(--admin-border);
border-radius: 16px;
background: var(--admin-bg-elevated);
text-align: center;
}
.banners-page__empty-icon {
color: var(--admin-text-secondary);
opacity: 0.65;
}
.banners-page__empty-title {
margin: 0.25rem 0 0;
font-size: 1rem;
font-weight: 600;
color: var(--admin-text);
}
.banners-page__empty-text {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: var(--admin-text-secondary);
line-height: 1.6;
}
.banners-page__list {
display: flex;
width: 100%;
flex-direction: column;
gap: 1rem;
}
.banner-card {
width: 100%;
overflow: hidden;
border: 1px solid var(--admin-border);
border-radius: 14px;
background: var(--admin-bg-elevated);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
&--hidden {
opacity: 0.88;
}
}
.banner-card__head {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.875rem 1rem;
border-bottom: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-bg-muted) 70%, transparent);
}
.banner-card__head-start {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.banner-card__index {
display: inline-flex;
width: 2.25rem;
height: 2.25rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--admin-primary, #f5b800);
color: #1a1a1a;
font-size: 0.9375rem;
font-weight: 800;
}
.banner-card__title {
font-size: 0.9375rem;
font-weight: 700;
color: var(--admin-text);
}
.banner-card__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin-top: 0.2rem;
}
.banner-card__pill {
font-size: 0.75rem;
color: #52c41a;
&--off {
color: var(--admin-text-secondary);
}
}
.banner-card__ratio {
font-size: 0.6875rem;
color: var(--admin-text-secondary);
}
.banner-card__head-end {
display: flex;
align-items: center;
gap: 0.75rem;
}
.banner-card__switch {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0;
font-size: 0.8125rem;
color: var(--admin-text-secondary);
cursor: pointer;
}
.banner-card__remove {
display: inline-flex;
width: 2.25rem;
height: 2.25rem;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 77, 79, 0.35);
border-radius: 10px;
background: rgba(255, 77, 79, 0.08);
color: #ff4d4f;
cursor: pointer;
transition: background 0.15s ease;
&:hover {
background: rgba(255, 77, 79, 0.16);
}
}
.banner-card__content {
display: grid;
grid-template-columns: 1fr;
gap: 1.25rem;
padding: 1rem;
@media (min-width: 900px) {
grid-template-columns: minmax(280px, 42%) minmax(0, 1fr);
padding: 1.125rem;
}
}
.banner-card__preview-wrap {
display: flex;
min-width: 0;
flex-direction: column;
gap: 0.5rem;
}
.banner-card__preview {
aspect-ratio: 16 / 9;
min-height: 11rem;
}
.banner-card__fields {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
min-width: 0;
align-content: start;
&--stack {
grid-template-columns: 1fr;
}
}
.banner-card__field {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
&--wide {
grid-column: 1 / -1;
}
}
.banner-card__message {
width: 100%;
min-height: 10.5rem;
padding: 0.75rem 0.875rem;
border: 1px solid var(--admin-border) !important;
border-radius: 10px !important;
background: var(--admin-bg-muted) !important;
color: var(--admin-text) !important;
font-size: 0.875rem;
line-height: 1.7;
resize: vertical;
&::placeholder {
color: var(--admin-text-secondary);
}
&:hover {
border-color: color-mix(in srgb, var(--admin-primary, #f5b800) 35%, var(--admin-border)) !important;
}
&:focus {
border-color: var(--admin-primary, #f5b800) !important;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--admin-primary, #f5b800) 18%, transparent);
outline: none;
}
2026-05-02 13:53:43 +03:30
}
2026-07-05 16:41:45 +03:30
</style>