added function for trigger in admin

This commit is contained in:
2026-07-06 19:06:57 +03:30
parent c380fc89d3
commit 6756641efd
14 changed files with 1072 additions and 442 deletions
@@ -0,0 +1,227 @@
<template>
<div class="banner-image-upload">
<a-upload
v-if="!imageUrl"
class="banner-image-upload__zone"
name="file"
accept="image/*"
:show-upload-list="false"
:action="uploadURL"
:headers="fileHeader"
@change="onChange"
>
<div class="banner-image-upload__empty">
<div v-if="loading" class="banner-image-upload__loading">
<LoadingOutlined />
<span>در حال آپلود...</span>
</div>
<template v-else>
<BaseIcon icon="vuesax-linear:gallery-add" size="40" class="banner-image-upload__icon" />
<span class="banner-image-upload__cta">افزودن تصویر بنر</span>
<span class="banner-image-upload__hint">کلیک کنید — تصویر افقی با نسبت ۱۶:۹</span>
</template>
</div>
</a-upload>
<div v-else class="banner-image-upload__filled">
<img :src="imageUrl" alt="" class="banner-image-upload__img" />
<div class="banner-image-upload__bar">
<a-upload
name="file"
accept="image/*"
:show-upload-list="false"
:action="uploadURL"
:headers="fileHeader"
@change="onChange"
>
<button type="button" class="banner-image-upload__btn banner-image-upload__btn--primary">
تغییر تصویر
</button>
</a-upload>
<button
type="button"
class="banner-image-upload__btn banner-image-upload__btn--danger"
@click="emit('remove')"
>
حذف تصویر
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { LoadingOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { uploadURL } from '@root/config'
import { getToken } from '@/utils'
import { normalizeUploadFileResponse } from '@/utils/file'
defineProps<{
imageUrl?: string | null
}>()
const emit = defineEmits<{
upload: [url: string]
remove: []
}>()
const loading = ref(false)
const fileHeader = computed(() => ({
Authorization: `Bearer ${getToken()}`,
}))
function onChange({ file }: { file: { status?: string; response?: unknown } }) {
if (file.status === 'uploading') {
loading.value = true
return
}
if (file.status === 'error') {
loading.value = false
message.error('خطا در آپلود تصویر')
return
}
if (file.status === 'done') {
loading.value = false
const fileResource = normalizeUploadFileResponse(file.response)
if (!fileResource?.full_src) {
message.error('خطا در ساخت آدرس تصویر')
return
}
emit('upload', fileResource.full_src)
message.success('تصویر با موفقیت آپلود شد')
}
}
</script>
<style scoped lang="less">
.banner-image-upload {
width: 100%;
height: 100%;
}
.banner-image-upload__zone {
display: block;
width: 100%;
height: 100%;
:deep(.ant-upload) {
display: block;
width: 100%;
height: 100%;
}
:deep(.ant-upload-select) {
display: block !important;
width: 100% !important;
height: 100% !important;
margin: 0 !important;
border: none !important;
background: transparent !important;
}
}
.banner-image-upload__empty {
display: flex;
width: 100%;
height: 100%;
min-height: 10rem;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1.25rem;
border: 2px dashed color-mix(in srgb, var(--admin-primary, #f5b800) 45%, var(--admin-border));
border-radius: 12px;
background: color-mix(in srgb, var(--admin-bg-muted) 85%, transparent);
color: var(--admin-text-secondary);
text-align: center;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
&:hover {
border-color: var(--admin-primary, #f5b800);
background: color-mix(in srgb, var(--admin-primary, #f5b800) 8%, var(--admin-bg-muted));
}
}
.banner-image-upload__icon {
color: var(--admin-primary, #f5b800);
opacity: 0.9;
}
.banner-image-upload__cta {
font-size: 0.9375rem;
font-weight: 600;
color: var(--admin-text);
}
.banner-image-upload__hint {
font-size: 0.75rem;
line-height: 1.5;
}
.banner-image-upload__loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.banner-image-upload__filled {
position: relative;
width: 100%;
height: 100%;
min-height: 10rem;
overflow: hidden;
border-radius: 12px;
border: 1px solid var(--admin-border);
background: #000;
}
.banner-image-upload__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.banner-image-upload__bar {
position: absolute;
inset: auto 0 0 0;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.78), transparent);
}
.banner-image-upload__btn {
padding: 0.4rem 0.85rem;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 8px;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s ease;
&--primary:hover {
background: rgba(0, 0, 0, 0.65);
}
&--danger {
border-color: rgba(255, 77, 79, 0.55);
color: #ffb3b3;
}
&--danger:hover {
background: rgba(255, 77, 79, 0.35);
}
}
</style>
+424 -116
View File
@@ -1,104 +1,126 @@
<template>
<div class="p-4">
<AdminFieldHelp :text="bannersPageIntro" root-class="mb-4 text-xs md:text-sm" />
<BaseButton size="small" type="primary" @click="addRow">
<span>افزودن بنر</span>
<BaseIcon icon="vuesax-linear:add" size="17" />
</BaseButton>
<AdminFieldHelp
text="هر بار «افزودن بنر» یک اسلاید جدید به انتهای لیست اضافه می‌کند."
root-class="mt-2"
/>
<div class="my-4 flex flex-col gap-2">
<div
class="grid max-h-52vh grid-cols-1 gap-4 overflow-y-scroll md:max-h-75vh md:grid-cols-3"
>
<Card v-for="(item, index) in benefitsList" :key="index">
<div class="mt-3 grid grid-cols-1 gap-4">
<div class="my-2 flex items-center justify-between gap-2">
<div class="flex flex-col gap-0.5">
<div class="text-sm font-medium">نسبت تصویر ۱۶:۹</div>
<AdminFieldHelp :text="bannersAspectHelp" />
</div>
<div class="flex shrink-0 flex-col items-end gap-0.5">
<div class="flex items-center gap-2">
<span>نمایش:</span>
<a-switch v-model:checked="item.show" />
</div>
<AdminFieldHelp :text="bannersShowHelp" root-class="text-left" />
</div>
</div>
<div class="text-center">
<AdminFieldHelp :text="bannersImageHelp" root-class="mb-2 text-center" />
<UploadPic
:compressed="true"
@upload="handleUpload($event, index, 'icon')"
@remove="handelRemoveFile(index, 'icon')"
:imageUrl="item.icon"
/>
</div>
</div>
<div class="mt-3 flex flex-col gap-1">
<div class="text-sm font-medium">انتخاب رویداد (پس از کلیک):</div>
<AdminFieldHelp :text="bannersEventHelp" />
<BasicSelect
v-model:value="item.typeClick"
:items="typeList"
value-name="lable"
key-name="value"
/>
<AdminFieldHelp v-if="item.typeClick === 'link'" :text="bannersLinkHelp" />
<AdminFieldHelp v-else-if="item.typeClick === 'modal'" :text="bannersModalHelp" />
<AdminFieldHelp v-else-if="item.typeClick === 'share'" :text="bannersShareHelp" />
<AdminFieldHelp v-else :text="bannersNoEventHelp" />
</div>
<div v-if="item.typeClick == 'link'" class="mt-2 flex flex-col gap-1">
<div class="text-sm font-medium">آدرس لینک</div>
<BasicInput v-model:value="item.link" placeholder="https://..." />
</div>
<div v-if="item.typeClick == 'modal'" class="mt-2 flex flex-col gap-1">
<div class="text-sm font-medium">متن پیام</div>
<QuillEditor
placeholder="متن پیام"
v-model:content="item.modalText"
toolbar="full"
theme="snow"
content-type="html"
class="editor-box2"
/>
</div>
</Card>
<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>
<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="mt-5 text-left md:mt-0">
<BaseButton type="primary" @click="handleSubmit">
<span>ذخیره تغییرات</span>
</BaseButton>
<AdminFieldHelp
text="بدون زدن این دکمه، هیچ‌کدام از تغییرات بالا در اپ مشتری دیده نمی‌شود."
root-class="mt-2"
/>
<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>
</div>
</div>
</template>
<script setup lang="ts">
import { Card, message } from 'ant-design-vue'
import { message } from 'ant-design-vue'
import type { IBenefits } from '@/model/banner'
import UploadPic from '@/components/UploadPic.vue'
import BannerImageUpload from './components/BannerImageUpload.vue'
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
import { updateBannerConst, getHeaderBannerConst } from '@/api/consts'
import {
bannersAspectHelp,
bannersEventHelp,
bannersImageHelp,
bannersImageSectionHelp,
bannersLinkHelp,
bannersModalHelp,
bannersNoEventHelp,
@@ -106,19 +128,45 @@ import {
bannersShareHelp,
bannersShowHelp,
} from '@/utils/adminHelpCopy'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
import { QuillEditor } from '@vueup/vue-quill'
import { normalizeUploadFileResponse } from '@/utils/file'
const benefitsList = ref<IBenefits[]>([])
const saving = ref(false)
let id: number | null = null
const typeList = ref([
{ lable: 'بدون رویداد', value: null },
{ lable: 'باز شدن لینک', value: 'link' },
{ lable: 'پنجره بازشو', value: 'modal' },
{ lable: 'اشتراک گذاری', value: 'share' },
{ lable: 'بدون رویداد', value: null },
{ lable: 'اشتراک‌گذاری', value: 'share' },
])
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>`
}
function parseBannerList(value: string | null | undefined): IBenefits[] {
if (!value) return []
try {
@@ -143,19 +191,8 @@ function addRow() {
})
}
function handleUpload(response: unknown, index: number, field: keyof IBenefits) {
const fileResource = normalizeUploadFileResponse(response)
if (!fileResource?.full_src) {
message.error('خطا در ساخت آدرس تصویر')
return
}
benefitsList.value[index][field] = fileResource.full_src
}
function handelRemoveFile(index: number, field: keyof IBenefits) {
benefitsList.value[index][field] = null
function removeRow(index: number) {
benefitsList.value.splice(index, 1)
}
async function handleSubmit() {
@@ -165,6 +202,7 @@ async function handleSubmit() {
}
try {
saving.value = true
await updateBannerConst({
id,
value: JSON.stringify(benefitsList.value),
@@ -173,6 +211,8 @@ async function handleSubmit() {
message.success('اطلاعات با موفقیت ذخیره شد')
} catch (error) {
message.error('خطا در ذخیره اطلاعات')
} finally {
saving.value = false
}
}
@@ -191,18 +231,286 @@ onMounted(async () => {
})
</script>
<style lang="less">
.editor-box2 {
max-height: 40vh;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 8px;
padding: 10px;
<style scoped lang="less">
.banners-page {
display: flex;
flex-direction: column;
width: 100%;
min-width: 0;
height: 100%;
min-height: 0;
}
.editor-box2 .ql-editor {
min-height: 10vh;
.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;
overflow-y: auto;
box-sizing: border-box;
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;
}
}
</style>