fix notif 3
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import type { TableInput } from '@core/type/baseTable'
|
||||
import useAxios from '@core/utils/useAxios'
|
||||
import type { AppNotification, AppNotificationReadersResponse } from '@/model/appNotifications'
|
||||
import type {
|
||||
AppNotification,
|
||||
AppNotificationCustomerOption,
|
||||
AppNotificationReadersResponse,
|
||||
AppNotificationRecipientsResponse,
|
||||
AppNotificationSegmentOption,
|
||||
} from '@/model/appNotifications'
|
||||
|
||||
export const getAppNotificationsTable = async (params: TableInput) =>
|
||||
await useAxios.post<Array<AppNotification>>('/admin/app_notifications/table', params)
|
||||
@@ -20,3 +26,27 @@ export const deleteAppNotification = async (id: number) =>
|
||||
export const getAppNotificationReaders = async (id: number) =>
|
||||
await useAxios.get<AppNotificationReadersResponse>(`/admin/app_notifications/${id}/readers`)
|
||||
|
||||
export const getAppNotificationRecipients = async (id: number) =>
|
||||
await useAxios.get<AppNotificationRecipientsResponse>(`/admin/app_notifications/${id}/recipients`)
|
||||
|
||||
export const getAppNotificationAudienceOptions = async () =>
|
||||
await useAxios.get<{ segments: AppNotificationSegmentOption[] }>(
|
||||
`/admin/app_notifications/audience-options`
|
||||
)
|
||||
|
||||
export const searchAppNotificationCustomers = async (q: string) =>
|
||||
await useAxios.get<{ customers: AppNotificationCustomerOption[] }>(
|
||||
`/admin/app_notifications/search-customers`,
|
||||
{ params: { q } }
|
||||
)
|
||||
|
||||
export const previewAppNotificationAudience = async (payload: {
|
||||
audience_type: string
|
||||
segment_keys?: string[]
|
||||
customer_ids?: number[]
|
||||
}) =>
|
||||
await useAxios.post<{
|
||||
count: number | null
|
||||
sample: AppNotificationCustomerOption[]
|
||||
label: string
|
||||
}>(`/admin/app_notifications/preview-audience`, payload)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export type AppNotificationAudienceType = 'all' | 'segment' | 'customers'
|
||||
|
||||
export interface AppNotification {
|
||||
id?: number
|
||||
title: string
|
||||
@@ -8,6 +10,12 @@ export interface AppNotification {
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
reads_count?: number
|
||||
audience_type?: AppNotificationAudienceType
|
||||
segment_keys?: string[] | null
|
||||
audience_label?: string | null
|
||||
recipients_count?: number
|
||||
/** فقط هنگام ایجاد — لیست id مشتریان تکی */
|
||||
customer_ids?: number[]
|
||||
}
|
||||
|
||||
export const APP_NOTIFICATION_SEVERITIES = [
|
||||
@@ -17,6 +25,24 @@ export const APP_NOTIFICATION_SEVERITIES = [
|
||||
{ value: 'danger', label: 'مهم / خطر (danger)' },
|
||||
] as const
|
||||
|
||||
export const APP_NOTIFICATION_AUDIENCE_TYPES = [
|
||||
{ value: 'all', label: 'همه کاربران' },
|
||||
{ value: 'segment', label: 'گروهی' },
|
||||
{ value: 'customers', label: 'تکی / چندنفره' },
|
||||
] as const
|
||||
|
||||
export interface AppNotificationSegmentOption {
|
||||
key: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface AppNotificationCustomerOption {
|
||||
id: number
|
||||
name?: string | null
|
||||
mobile?: string | null
|
||||
}
|
||||
|
||||
export interface AppNotificationReader {
|
||||
customer_id: number
|
||||
name: string
|
||||
@@ -28,3 +54,11 @@ export interface AppNotificationReadersResponse {
|
||||
reads_count: number
|
||||
readers: AppNotificationReader[]
|
||||
}
|
||||
|
||||
export interface AppNotificationRecipientsResponse {
|
||||
audience_type: AppNotificationAudienceType
|
||||
audience_label?: string | null
|
||||
recipients_count: number | null
|
||||
recipients: Array<{ customer_id: number; name: string; mobile?: string | null }>
|
||||
note?: string
|
||||
}
|
||||
|
||||
+519
-33
@@ -1,55 +1,541 @@
|
||||
<template>
|
||||
<a-form :model="form" ref="formRef" layout="vertical" autocomplete="off">
|
||||
<a-form-item label="عنوان" name="title" :rules="inputRules()">
|
||||
<BasicInput v-model:value="form.title" placeholder="عنوان اعلان" />
|
||||
</a-form-item>
|
||||
<a-form
|
||||
:model="form"
|
||||
ref="formRef"
|
||||
layout="vertical"
|
||||
autocomplete="off"
|
||||
class="notif-editor"
|
||||
>
|
||||
<div class="notif-editor__grid">
|
||||
<div class="notif-editor__main">
|
||||
<a-form-item label="عنوان" name="title" :rules="inputRules()" class="notif-editor__item notif-editor__item--title">
|
||||
<BasicInput v-model:value="form.title" placeholder="عنوان کوتاه اعلان" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="توضیح" name="body" :rules="inputRules()">
|
||||
<a-textarea
|
||||
v-model:value="form.body"
|
||||
:rows="5"
|
||||
placeholder="متن اعلان برای مشتری"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="متن"
|
||||
name="body"
|
||||
:rules="inputRules()"
|
||||
class="notif-editor__item notif-editor__item--body"
|
||||
>
|
||||
<a-textarea
|
||||
v-model:value="form.body"
|
||||
:rows="bodyRows"
|
||||
:auto-size="bodyAutoSize"
|
||||
placeholder="متن اعلان برای مشتری"
|
||||
class="notif-editor__textarea"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<a-form-item label="اهمیت" name="severity" :rules="inputRules()">
|
||||
<BasicSelect
|
||||
v-model:value="form.severity"
|
||||
:items="severityItems"
|
||||
key-name="value"
|
||||
value-name="label"
|
||||
placeholder="انتخاب اهمیت"
|
||||
/>
|
||||
</a-form-item>
|
||||
<div class="notif-editor__side">
|
||||
<div class="notif-editor__row">
|
||||
<a-form-item
|
||||
label="اهمیت"
|
||||
name="severity"
|
||||
:rules="inputRules()"
|
||||
class="notif-editor__item notif-editor__item--grow"
|
||||
>
|
||||
<a-radio-group
|
||||
v-model:value="form.severity"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
class="notif-editor__severity"
|
||||
>
|
||||
<a-radio-button
|
||||
v-for="item in severityItems"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.short }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="وضعیت" name="is_active">
|
||||
<a-switch
|
||||
v-model:checked="form.is_active"
|
||||
checked-children="فعال"
|
||||
un-checked-children="غیرفعال"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
label="وضعیت"
|
||||
name="is_active"
|
||||
class="notif-editor__item notif-editor__item--status"
|
||||
>
|
||||
<div class="notif-editor__status">
|
||||
<a-switch v-model:checked="form.is_active" />
|
||||
<span class="notif-editor__status-label">
|
||||
{{ form.is_active ? 'فعال' : 'غیرفعال' }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<template v-if="!isEdit">
|
||||
<a-form-item
|
||||
label="مخاطب"
|
||||
name="audience_type"
|
||||
:rules="inputRules()"
|
||||
class="notif-editor__item notif-editor__item--audience"
|
||||
>
|
||||
<a-radio-group
|
||||
v-model:value="form.audience_type"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
class="notif-editor__audience"
|
||||
>
|
||||
<a-radio-button
|
||||
v-for="item in audienceTypes"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<div v-if="form.audience_type === 'segment'" class="notif-editor__segments">
|
||||
<div class="notif-editor__section-label">انتخاب گروه</div>
|
||||
<a-radio-group
|
||||
:value="selectedSegment"
|
||||
class="notif-editor__segment-grid"
|
||||
@update:value="selectSegment"
|
||||
>
|
||||
<label
|
||||
v-for="seg in segments"
|
||||
:key="seg.key"
|
||||
class="notif-editor__chip"
|
||||
:class="{ 'notif-editor__chip--on': selectedSegment === seg.key }"
|
||||
:title="seg.description"
|
||||
>
|
||||
<a-radio :value="seg.key" />
|
||||
<span class="notif-editor__chip-text">{{ seg.name }}</span>
|
||||
</label>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<a-form-item
|
||||
v-if="form.audience_type === 'customers'"
|
||||
label="مشتریان"
|
||||
class="notif-editor__item notif-editor__item--customers"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="form.customer_ids"
|
||||
mode="multiple"
|
||||
show-search
|
||||
:filter-option="false"
|
||||
:options="customerOptions"
|
||||
:loading="customersLoading"
|
||||
placeholder="جستجوی نام یا موبایل…"
|
||||
class="w-full"
|
||||
:max-tag-count="2"
|
||||
@search="onCustomerSearch"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<div class="notif-editor__preview">
|
||||
<span v-if="previewLoading" class="notif-editor__preview-muted">در حال برآورد…</span>
|
||||
<template v-else>
|
||||
<span class="notif-editor__preview-dot" />
|
||||
<span>
|
||||
{{ previewLabel || 'مخاطب را انتخاب کنید' }}
|
||||
<template v-if="previewCount != null">
|
||||
· <b>{{ previewCount }}</b> نفر
|
||||
</template>
|
||||
<template v-else-if="form.audience_type === 'all'">
|
||||
· همه
|
||||
</template>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-alert
|
||||
v-else
|
||||
type="info"
|
||||
show-icon
|
||||
class="notif-editor__alert"
|
||||
message="مخاطب بعد از ارسال ثابت است؛ فقط متن و وضعیت قابل ویرایش است."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import type { AppNotification } from '@/model/appNotifications'
|
||||
import { APP_NOTIFICATION_SEVERITIES } from '@/model/appNotifications'
|
||||
import type {
|
||||
AppNotification,
|
||||
AppNotificationCustomerOption,
|
||||
AppNotificationSegmentOption,
|
||||
} from '@/model/appNotifications'
|
||||
import { APP_NOTIFICATION_AUDIENCE_TYPES } from '@/model/appNotifications'
|
||||
import {
|
||||
getAppNotificationAudienceOptions,
|
||||
previewAppNotificationAudience,
|
||||
searchAppNotificationCustomers,
|
||||
} from '@/api/appNotifications'
|
||||
import { inputRules } from '@/utils/inputRules'
|
||||
import BasicInput from '@/components/BasicInput.vue'
|
||||
import BasicSelect from '@/components/BasicSelect.vue'
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
form: Partial<AppNotification>
|
||||
isEdit?: boolean
|
||||
}>()
|
||||
|
||||
const severityItems = [...APP_NOTIFICATION_SEVERITIES]
|
||||
const severityItems = [
|
||||
{ value: 'info', short: 'اطلاعات' },
|
||||
{ value: 'success', short: 'موفق' },
|
||||
{ value: 'warning', short: 'هشدار' },
|
||||
{ value: 'danger', short: 'مهم' },
|
||||
] as const
|
||||
|
||||
const audienceTypes = [...APP_NOTIFICATION_AUDIENCE_TYPES]
|
||||
const formRef = ref<FormInstance>()
|
||||
const segments = ref<AppNotificationSegmentOption[]>([])
|
||||
const customersLoading = ref(false)
|
||||
const customerOptions = ref<Array<{ value: number; label: string }>>([])
|
||||
const previewLoading = ref(false)
|
||||
const previewCount = ref<number | null>(null)
|
||||
const previewLabel = ref('')
|
||||
const isNarrow = ref(false)
|
||||
|
||||
const isEdit = computed(() => Boolean(props.isEdit))
|
||||
const selectedSegment = computed(() => props.form.segment_keys?.[0] || undefined)
|
||||
|
||||
const bodyRows = computed(() => (isNarrow.value ? 3 : 8))
|
||||
const bodyAutoSize = computed(() =>
|
||||
isNarrow.value
|
||||
? { minRows: 3, maxRows: 5 }
|
||||
: { minRows: 8, maxRows: 14 }
|
||||
)
|
||||
|
||||
function selectSegment(key: string) {
|
||||
props.form.segment_keys = key ? [key] : []
|
||||
}
|
||||
|
||||
function syncNarrow() {
|
||||
isNarrow.value = typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.form.audience_type,
|
||||
(type) => {
|
||||
if (type !== 'segment') return
|
||||
const keys = props.form.segment_keys || []
|
||||
if (keys.length > 1) {
|
||||
props.form.segment_keys = [keys[0]]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
async function loadSegments() {
|
||||
try {
|
||||
const data = await getAppNotificationAudienceOptions()
|
||||
segments.value = Array.isArray(data?.segments) ? data.segments : []
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const onCustomerSearch = debounce(async (q: string) => {
|
||||
customersLoading.value = true
|
||||
try {
|
||||
const data = await searchAppNotificationCustomers(q || '')
|
||||
const rows = Array.isArray(data?.customers) ? data.customers : []
|
||||
customerOptions.value = rows.map((c: AppNotificationCustomerOption) => ({
|
||||
value: c.id,
|
||||
label: `${c.name || 'بدون نام'} — ${c.mobile || c.id}`,
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
customersLoading.value = false
|
||||
}
|
||||
}, 300)
|
||||
|
||||
async function refreshPreview() {
|
||||
if (isEdit.value) return
|
||||
if (props.form.audience_type === 'all') {
|
||||
previewCount.value = null
|
||||
previewLabel.value = 'همه کاربران'
|
||||
return
|
||||
}
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const data = await previewAppNotificationAudience({
|
||||
audience_type: String(props.form.audience_type || 'all'),
|
||||
segment_keys: props.form.segment_keys || [],
|
||||
customer_ids: props.form.customer_ids || [],
|
||||
})
|
||||
previewCount.value = data?.count ?? null
|
||||
previewLabel.value = data?.label || ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.form.audience_type,
|
||||
...(props.form.segment_keys || []),
|
||||
...(props.form.customer_ids || []),
|
||||
],
|
||||
() => {
|
||||
void refreshPreview()
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
syncNarrow()
|
||||
window.addEventListener('resize', syncNarrow)
|
||||
void loadSegments()
|
||||
void onCustomerSearch('')
|
||||
void refreshPreview()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', syncNarrow)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
validateFields: () => formRef.value?.validateFields(),
|
||||
resetFields: () => formRef.value?.resetFields(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notif-editor__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.25rem; /* حداقل gap-5 */
|
||||
}
|
||||
|
||||
/* دسکتاپ: راست عنوان/متن (فضای بیشتر)، چپ بقیه */
|
||||
@media (min-width: 768px) {
|
||||
.notif-editor__grid {
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.notif-editor__main {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.notif-editor__item--body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.notif-editor__textarea {
|
||||
min-height: 12rem;
|
||||
}
|
||||
|
||||
.notif-editor__segment-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* موبایل: تکستونه و مینیمال */
|
||||
@media (max-width: 767px) {
|
||||
.notif-editor__grid {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.notif-editor :deep(.ant-form-item) {
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.notif-editor__segment-grid {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.notif-editor__severity :deep(.ant-radio-button-wrapper),
|
||||
.notif-editor__audience :deep(.ant-radio-button-wrapper) {
|
||||
font-size: 0.75rem;
|
||||
padding-inline: 0.35rem;
|
||||
}
|
||||
|
||||
.notif-editor__chip {
|
||||
min-height: 2rem;
|
||||
padding: 0.35rem 0.45rem;
|
||||
}
|
||||
|
||||
.notif-editor__chip-text {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.notif-editor :deep(.ant-form-item) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.notif-editor__side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notif-editor__side > .notif-editor__row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.notif-editor__item--audience {
|
||||
margin-top: 1.25rem !important;
|
||||
}
|
||||
|
||||
.notif-editor :deep(.ant-form-item-label) {
|
||||
padding-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.notif-editor :deep(.ant-form-item-label > label) {
|
||||
font-size: 0.8125rem;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.notif-editor__item--grow {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notif-editor__item--status {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.notif-editor__row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notif-editor__severity,
|
||||
.notif-editor__audience {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notif-editor__severity :deep(.ant-radio-button-wrapper),
|
||||
.notif-editor__audience :deep(.ant-radio-button-wrapper) {
|
||||
flex: 1 1 auto;
|
||||
text-align: center;
|
||||
height: 2rem;
|
||||
line-height: 1.875rem;
|
||||
font-size: 0.8125rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.notif-editor__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.notif-editor__status-label {
|
||||
font-size: 0.8125rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.notif-editor__section-label {
|
||||
font-size: 0.8125rem;
|
||||
margin-bottom: 0.4rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.notif-editor__item--title {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.notif-editor__item--body {
|
||||
margin-top: 1.25rem !important;
|
||||
}
|
||||
|
||||
.notif-editor__segments {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.notif-editor__item--customers {
|
||||
margin-top: 1.25rem !important;
|
||||
}
|
||||
|
||||
.notif-editor__segment-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.35rem !important;
|
||||
row-gap: 0.35rem !important;
|
||||
column-gap: 0.35rem !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.notif-editor__segment-grid :deep(.ant-radio-wrapper),
|
||||
.notif-editor__segment-grid > .notif-editor__chip {
|
||||
margin-inline-end: 0 !important;
|
||||
}
|
||||
|
||||
.notif-editor__chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
min-height: 2.15rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.notif-editor__chip:hover {
|
||||
border-color: rgba(37, 99, 235, 0.45);
|
||||
}
|
||||
|
||||
.notif-editor__chip--on {
|
||||
border-color: rgba(37, 99, 235, 0.55);
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.notif-editor__chip :deep(.ant-radio-wrapper) {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.notif-editor__chip-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.notif-editor__preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.notif-editor__preview-dot {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: #2563eb;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notif-editor__preview-muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.notif-editor__alert {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<template #extra>
|
||||
<div class="flex items-center gap-2">
|
||||
<AdminFieldHelp
|
||||
text="اعلانهای ثبتشده برای همهٔ مشتریان فعال در اپ نمایش داده میشود. فقط وقتی سوییچ «اعلان داخل اپ» روشن باشد."
|
||||
text="اعلان را برای همه، گروه (مثلاً کسانی که مدتی نیامدهاند) یا مشتری تکی بفرستید. کاربران آنلاین از طریق اتصال زنده اپ، بلافاصله بج اعلان را میبینند."
|
||||
/>
|
||||
<BaseButton type="primary" size="small" @click="emits('add')">
|
||||
<span>افزودن اعلان</span>
|
||||
@@ -16,6 +16,15 @@
|
||||
<BaseTable v-bind="tblProps" @register="submit" ref="tbl">
|
||||
<template #action="{ record }">
|
||||
<div class="flex justify-center gap-2">
|
||||
<a-tooltip placement="bottom">
|
||||
<template #title>گیرندگان</template>
|
||||
<Icon
|
||||
icon="vuesax-linear:profile-2user"
|
||||
color="#7C3AED"
|
||||
class="cursor-pointer"
|
||||
@click="emits('recipients', record)"
|
||||
/>
|
||||
</a-tooltip>
|
||||
<a-tooltip placement="bottom">
|
||||
<template #title>بازدیدکنندگان</template>
|
||||
<Icon
|
||||
@@ -75,6 +84,7 @@ const emits = defineEmits<{
|
||||
toggle: [record: Record<string, unknown>]
|
||||
delete: [id: number]
|
||||
readers: [record: Record<string, unknown>]
|
||||
recipients: [record: Record<string, unknown>]
|
||||
}>()
|
||||
|
||||
const tblProps = {
|
||||
|
||||
@@ -16,6 +16,18 @@ const severityColor: Record<string, string> = {
|
||||
danger: 'red',
|
||||
}
|
||||
|
||||
const audienceTypeLabel: Record<string, string> = {
|
||||
all: 'همه',
|
||||
segment: 'گروهی',
|
||||
customers: 'تکی',
|
||||
}
|
||||
|
||||
const audienceTypeColor: Record<string, string> = {
|
||||
all: 'purple',
|
||||
segment: 'cyan',
|
||||
customers: 'gold',
|
||||
}
|
||||
|
||||
export const cols: BaseTableColumn[] = [
|
||||
{
|
||||
title: 'ردیف',
|
||||
@@ -29,6 +41,29 @@ export const cols: BaseTableColumn[] = [
|
||||
key: 'title',
|
||||
type: 'Text',
|
||||
},
|
||||
{
|
||||
title: 'نوع ارسال',
|
||||
dataIndex: 'audience_type',
|
||||
key: 'audience_type',
|
||||
customRender: ({ record }) => (
|
||||
<CustomTag color={audienceTypeColor[record.audience_type] || 'default'}>
|
||||
{audienceTypeLabel[record.audience_type] || record.audience_type || 'همه'}
|
||||
</CustomTag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'مخاطب',
|
||||
dataIndex: 'audience_label',
|
||||
key: 'audience_label',
|
||||
customRender: ({ record }) => {
|
||||
const label = record.audience_label || 'همه کاربران'
|
||||
const count =
|
||||
record.audience_type && record.audience_type !== 'all'
|
||||
? ` (${Number(record.recipients_count ?? 0)} نفر)`
|
||||
: ''
|
||||
return `${label}${count}`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'اهمیت',
|
||||
dataIndex: 'severity',
|
||||
@@ -68,6 +103,6 @@ export const cols: BaseTableColumn[] = [
|
||||
slot: 'action',
|
||||
fixed: 'right',
|
||||
filterable: false,
|
||||
width: 160,
|
||||
width: 200,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
@toggle="toggleActive"
|
||||
@delete="deleteItem"
|
||||
@readers="openReaders"
|
||||
@recipients="openRecipients"
|
||||
/>
|
||||
|
||||
<BasicActionSheet
|
||||
v-model:visible="editorVisible"
|
||||
:title="sheetTitle"
|
||||
modal-width="32rem"
|
||||
modal-width="48rem"
|
||||
drawer-height="85vh"
|
||||
:loading="loading"
|
||||
:show-button="true"
|
||||
@@ -26,7 +27,11 @@
|
||||
@cancel="closeEditor"
|
||||
>
|
||||
<div v-if="isMobile" class="mb-3 text-base font-bold">{{ sheetTitle }}</div>
|
||||
<AppNotificationEditorFormBody ref="editorRef" :form="form" />
|
||||
<AppNotificationEditorFormBody
|
||||
ref="editorRef"
|
||||
:form="form"
|
||||
:is-edit="Boolean(editId)"
|
||||
/>
|
||||
</BasicActionSheet>
|
||||
|
||||
<BasicActionSheet
|
||||
@@ -65,6 +70,43 @@
|
||||
</div>
|
||||
</a-spin>
|
||||
</BasicActionSheet>
|
||||
|
||||
<BasicActionSheet
|
||||
v-model:visible="recipientsVisible"
|
||||
:title="recipientsTitle"
|
||||
modal-width="28rem"
|
||||
drawer-height="70vh"
|
||||
:loading="recipientsLoading"
|
||||
:show-button="true"
|
||||
:show-cancel="false"
|
||||
:show-ok="true"
|
||||
ok-text="بستن"
|
||||
@ok="recipientsVisible = false"
|
||||
@close="recipientsVisible = false"
|
||||
>
|
||||
<div v-if="isMobile" class="mb-3 text-base font-bold">{{ recipientsTitle }}</div>
|
||||
<a-spin :spinning="recipientsLoading">
|
||||
<div v-if="recipientsNote" class="mb-3 text-sm text-gray-600">{{ recipientsNote }}</div>
|
||||
<div v-if="!recipients.length && !recipientsNote" class="py-8 text-center text-gray-500">
|
||||
گیرندهای ثبت نشده است.
|
||||
</div>
|
||||
<div v-else-if="recipients.length" class="readers-scroll flex flex-col gap-2">
|
||||
<div class="mb-1 text-xs text-gray-500">
|
||||
{{ recipientsCountLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-for="row in recipients"
|
||||
:key="row.customer_id"
|
||||
class="flex items-center justify-between gap-3 rounded-lg border border-solid border-gray-100 px-3 py-2"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-bold">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-500" dir="ltr">{{ row.mobile || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-spin>
|
||||
</BasicActionSheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -80,6 +122,7 @@ import {
|
||||
createAppNotification,
|
||||
deleteAppNotification,
|
||||
getAppNotificationReaders,
|
||||
getAppNotificationRecipients,
|
||||
toggleAppNotificationActive,
|
||||
updateAppNotification,
|
||||
} from '@/api/appNotifications'
|
||||
@@ -102,11 +145,21 @@ const readersLoading = ref(false)
|
||||
const readers = ref<AppNotificationReader[]>([])
|
||||
const readersNotifTitle = ref('')
|
||||
|
||||
const recipientsVisible = ref(false)
|
||||
const recipientsLoading = ref(false)
|
||||
const recipients = ref<Array<{ customer_id: number; name: string; mobile?: string | null }>>([])
|
||||
const recipientsNotifTitle = ref('')
|
||||
const recipientsNote = ref('')
|
||||
const recipientsTotal = ref<number | null>(null)
|
||||
|
||||
const defaultForm = (): Partial<AppNotification> => ({
|
||||
title: '',
|
||||
body: '',
|
||||
severity: 'info',
|
||||
is_active: true,
|
||||
audience_type: 'all',
|
||||
segment_keys: [],
|
||||
customer_ids: [],
|
||||
})
|
||||
|
||||
const form = ref<Partial<AppNotification>>(defaultForm())
|
||||
@@ -115,6 +168,13 @@ const sheetTitle = computed(() => (editId.value ? 'ویرایش اعلان' : '
|
||||
const readersTitle = computed(() =>
|
||||
readersNotifTitle.value ? `بازدیدکنندگان — ${readersNotifTitle.value}` : 'بازدیدکنندگان اعلان'
|
||||
)
|
||||
const recipientsTitle = computed(() =>
|
||||
recipientsNotifTitle.value ? `گیرندگان — ${recipientsNotifTitle.value}` : 'گیرندگان اعلان'
|
||||
)
|
||||
const recipientsCountLabel = computed(() => {
|
||||
if (recipientsTotal.value == null) return `${recipients.value.length} نفر`
|
||||
return `${recipientsTotal.value} نفر`
|
||||
})
|
||||
|
||||
function refreshTable() {
|
||||
tableRef.value?.fetch()
|
||||
@@ -160,22 +220,62 @@ async function openReaders(record: AppNotification) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openRecipients(record: AppNotification) {
|
||||
if (!record.id) return
|
||||
recipientsNotifTitle.value = record.title || ''
|
||||
recipientsVisible.value = true
|
||||
recipientsLoading.value = true
|
||||
recipients.value = []
|
||||
recipientsNote.value = ''
|
||||
recipientsTotal.value = null
|
||||
try {
|
||||
const data = await getAppNotificationRecipients(record.id)
|
||||
recipients.value = Array.isArray(data?.recipients) ? data.recipients : []
|
||||
recipientsNote.value = data?.note || data?.audience_label || ''
|
||||
recipientsTotal.value = data?.recipients_count ?? null
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error('بارگذاری لیست گیرندگان ناموفق بود')
|
||||
} finally {
|
||||
recipientsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
try {
|
||||
await editorRef.value?.validateFields?.()
|
||||
loading.value = true
|
||||
|
||||
const payload = {
|
||||
title: String(form.value.title ?? '').trim(),
|
||||
body: String(form.value.body ?? '').trim(),
|
||||
severity: form.value.severity || 'info',
|
||||
is_active: Boolean(form.value.is_active),
|
||||
if (!editId.value) {
|
||||
const type = form.value.audience_type || 'all'
|
||||
if (type === 'segment' && !(form.value.segment_keys?.length)) {
|
||||
message.warning('یک گروه انتخاب کنید')
|
||||
return
|
||||
}
|
||||
if (type === 'customers' && !(form.value.customer_ids?.length)) {
|
||||
message.warning('حداقل یک مشتری انتخاب کنید')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
if (editId.value) {
|
||||
await updateAppNotification(editId.value, payload)
|
||||
await updateAppNotification(editId.value, {
|
||||
title: String(form.value.title ?? '').trim(),
|
||||
body: String(form.value.body ?? '').trim(),
|
||||
severity: form.value.severity || 'info',
|
||||
is_active: Boolean(form.value.is_active),
|
||||
})
|
||||
} else {
|
||||
await createAppNotification(payload)
|
||||
await createAppNotification({
|
||||
title: String(form.value.title ?? '').trim(),
|
||||
body: String(form.value.body ?? '').trim(),
|
||||
severity: form.value.severity || 'info',
|
||||
is_active: Boolean(form.value.is_active),
|
||||
audience_type: form.value.audience_type || 'all',
|
||||
segment_keys: form.value.segment_keys || [],
|
||||
customer_ids: form.value.customer_ids || [],
|
||||
})
|
||||
}
|
||||
|
||||
message.success('اعلان با موفقیت ذخیره شد')
|
||||
|
||||
Reference in New Issue
Block a user