fix notif

This commit is contained in:
2026-07-24 17:01:40 +03:30
parent ab83ba7e6a
commit 3c41a3d23f
11 changed files with 147 additions and 24 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
import type { TableInput } from '@core/type/baseTable'
import useAxios from '@core/utils/useAxios'
import type { AppNotification } from '@/model/appNotifications'
import type { AppNotification, AppNotificationReadersResponse } from '@/model/appNotifications'
export const getAppNotificationsTable = async (params: TableInput) =>
await useAxios.post<Array<AppNotification>>('/admin/app_notifications/table', params)
@@ -16,3 +16,7 @@ export const toggleAppNotificationActive = async (id: number) =>
export const deleteAppNotification = async (id: number) =>
await useAxios.delete(`/admin/app_notifications/${id}`)
export const getAppNotificationReaders = async (id: number) =>
await useAxios.get<AppNotificationReadersResponse>(`/admin/app_notifications/${id}/readers`)
+13
View File
@@ -7,6 +7,7 @@ export interface AppNotification {
created_by?: number | null
created_at?: string
updated_at?: string
reads_count?: number
}
export const APP_NOTIFICATION_SEVERITIES = [
@@ -15,3 +16,15 @@ export const APP_NOTIFICATION_SEVERITIES = [
{ value: 'warning', label: 'هشدار (warning)' },
{ value: 'danger', label: 'مهم / خطر (danger)' },
] as const
export interface AppNotificationReader {
customer_id: number
name: string
mobile?: string | null
read_at?: string | null
}
export interface AppNotificationReadersResponse {
reads_count: number
readers: AppNotificationReader[]
}
@@ -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:people"
color="#10913A"
class="cursor-pointer"
@click="emits('readers', record)"
/>
</a-tooltip>
<a-tooltip placement="bottom">
<template #title>ویرایش</template>
<Icon
@@ -28,7 +37,7 @@
<a-tooltip placement="bottom">
<template #title>{{ record.is_active ? 'غیرفعال کردن' : 'فعال کردن' }}</template>
<Icon
:icon="record.is_active ? 'vuesax-linear:eye-slash' : 'vuesax-linear:eye'"
:icon="record.is_active ? 'vuesax-linear:close-circle' : 'vuesax-linear:tick-circle'"
:color="record.is_active ? '#C53030' : '#25855A'"
class="cursor-pointer"
@click="emits('toggle', record)"
@@ -65,6 +74,7 @@ const emits = defineEmits<{
edit: [record: Record<string, unknown>]
toggle: [record: Record<string, unknown>]
delete: [id: number]
readers: [record: Record<string, unknown>]
}>()
const tblProps = {
@@ -49,6 +49,12 @@ export const cols: BaseTableColumn[] = [
</CustomTag>
),
},
{
title: 'بازدیدکنندگان',
dataIndex: 'reads_count',
key: 'reads_count',
customRender: ({ record }) => Number(record.reads_count ?? 0),
},
{
title: 'تاریخ',
dataIndex: 'created_at',
@@ -59,5 +65,9 @@ export const cols: BaseTableColumn[] = [
title: 'عملیات',
dataIndex: 'action',
key: 'action',
slot: 'action',
fixed: 'right',
filterable: false,
width: 160,
},
]
+75 -1
View File
@@ -7,6 +7,7 @@
@edit="openEdit"
@toggle="toggleActive"
@delete="deleteItem"
@readers="openReaders"
/>
<BasicActionSheet
@@ -27,6 +28,43 @@
<div v-if="isMobile" class="mb-3 text-base font-bold">{{ sheetTitle }}</div>
<AppNotificationEditorFormBody ref="editorRef" :form="form" />
</BasicActionSheet>
<BasicActionSheet
v-model:visible="readersVisible"
:title="readersTitle"
modal-width="28rem"
drawer-height="70vh"
:loading="readersLoading"
:show-button="true"
:show-cancel="false"
:show-ok="true"
ok-text="بستن"
@ok="readersVisible = false"
@close="readersVisible = false"
>
<div v-if="isMobile" class="mb-3 text-base font-bold">{{ readersTitle }}</div>
<a-spin :spinning="readersLoading">
<div v-if="!readers.length" class="py-8 text-center text-gray-500">
هنوز کسی این اعلان را بازدید نکرده است.
</div>
<div v-else class="readers-scroll flex flex-col gap-2">
<div class="mb-1 text-xs text-gray-500">{{ readers.length }} بازدیدکننده</div>
<div
v-for="row in readers"
: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 class="shrink-0 text-xs text-gray-500">
{{ row.read_at ? toJalali(row.read_at) : '—' }}
</div>
</div>
</div>
</a-spin>
</BasicActionSheet>
</div>
</template>
@@ -37,15 +75,17 @@ import { cloneDeep } from 'lodash'
import tbl from './components/table.vue'
import BasicActionSheet from '@/components/BasicActionSheet.vue'
import AppNotificationEditorFormBody from './components/AppNotificationEditorFormBody.vue'
import type { AppNotification } from '@/model/appNotifications'
import type { AppNotification, AppNotificationReader } from '@/model/appNotifications'
import {
createAppNotification,
deleteAppNotification,
getAppNotificationReaders,
toggleAppNotificationActive,
updateAppNotification,
} from '@/api/appNotifications'
import { confirmDeleteItem } from '@/utils/confirmModal'
import { isMobile } from '@core/utils/is'
import { toJalali } from '@core/utils/useDataTime'
const tableRef = ref<null | { fetch: () => void }>(null)
const editorRef = ref<{
@@ -57,6 +97,11 @@ const loading = ref(false)
const editorVisible = ref(false)
const editId = ref<number | undefined>()
const readersVisible = ref(false)
const readersLoading = ref(false)
const readers = ref<AppNotificationReader[]>([])
const readersNotifTitle = ref('')
const defaultForm = (): Partial<AppNotification> => ({
title: '',
body: '',
@@ -67,6 +112,9 @@ const defaultForm = (): Partial<AppNotification> => ({
const form = ref<Partial<AppNotification>>(defaultForm())
const sheetTitle = computed(() => (editId.value ? 'ویرایش اعلان' : 'افزودن اعلان'))
const readersTitle = computed(() =>
readersNotifTitle.value ? `بازدیدکنندگان — ${readersNotifTitle.value}` : 'بازدیدکنندگان اعلان'
)
function refreshTable() {
tableRef.value?.fetch()
@@ -94,6 +142,24 @@ function openEdit(record: AppNotification) {
editorVisible.value = true
}
async function openReaders(record: AppNotification) {
if (!record.id) return
readersNotifTitle.value = record.title || ''
readersVisible.value = true
readersLoading.value = true
readers.value = []
try {
const data = await getAppNotificationReaders(record.id)
readers.value = Array.isArray(data?.readers) ? data.readers : []
refreshTable()
} catch (e) {
console.error(e)
message.error('بارگذاری لیست بازدیدکنندگان ناموفق بود')
} finally {
readersLoading.value = false
}
}
async function submitForm() {
try {
await editorRef.value?.validateFields?.()
@@ -142,3 +208,11 @@ async function deleteItem(id: number) {
confirmDeleteItem('اعلان', id, deleteAppNotification, refreshTable)
}
</script>
<style scoped>
.readers-scroll {
max-height: min(50vh, 28rem);
overflow-y: auto;
padding-inline-end: 0.25rem;
}
</style>
+1 -1
View File
@@ -322,7 +322,7 @@ const defaultOpenGroupId = computed(() => {
const visibleIds = visibleSettingGroups.value.map((g) => g.id)
const preferred = isSystemMode.value
? ['chart', 'pricing', 'payping', 'accounting', 'store', 'general']
: ['general', 'content', 'pricing', 'price_source', 'referral']
: ['general', 'content', 'fees', 'limits', 'price_source', 'referral']
const fromPreferred = preferred.find((id) => visibleIds.includes(id))
return fromPreferred ?? visibleIds[0]
})
@@ -27,16 +27,24 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
id: 'trading_hours',
title: 'ساعت کاری و نمایش قیمت',
icon: 'vuesax-linear:clock',
match: (k) => /^(close_price|startDay|endDay|show_last_price|show_gold_ounce_price)$/.test(k)
match: (k) =>
/^(close_price|startDay|endDay|show_last_price|show_home_price_when_closed|show_gold_ounce_price)$/.test(
k
)
},
{
id: 'pricing',
title: 'کارمزد و محدودیت‌ها',
id: 'fees',
title: 'کارمزدها',
icon: 'vuesax-linear:dollar-circle',
match: (k) =>
/^(fee_|buy_fee|sell_fee|min_|minimum_|up_price|down_price|price_extreme_date|balance_report_exclude)/.test(
k
) || k.includes('charge_percentage')
/^(fee_|buy_fee|sell_fee|up_price|down_price|price_extreme_date)/.test(k) ||
k.includes('charge_percentage')
},
{
id: 'limits',
title: 'محدودیت‌ها',
icon: 'vuesax-linear:lock',
match: (k) => /^(min_|minimum_|balance_report_exclude)/.test(k)
},
{
id: 'price_source',
@@ -266,6 +266,7 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
startDay: 'time',
endDay: 'time',
show_last_price: 'switch',
show_home_price_when_closed: 'switch',
show_gold_ounce_price: 'switch',
send_to_tahesab: 'switch',
accounting_provider: 'select',
@@ -549,13 +550,15 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
auto_active_customer:
'روشن: بعد از ثبت‌نام، کاربر می‌تواند وارد اپ شود (بدون تأیید دستی ادمین). خاموش: ورود فقط بعد از فعال‌سازی دستی شما در پنل. این سوییچ اجازهٔ خرید/فروش نمی‌دهد — معامله فقط بعد از احراز هویت (is_legal) ممکن است.',
close_price:
'فیلتر اصلی ساعت کاری فروشگاه. روشن: خرید، فروش و نمایش قیمت فقط بین «ساعت شروع» و «ساعت پایان» انجام می‌شود و خارج از این بازه به مشتری «فروشگاه بسته است» نشان داده می‌شود. خاموش: محدودیت ساعتی سراسری اعمال نمی‌شود (مگر برای محصولی ساعت اختصاصی تعریف کرده باشید).',
'فیلتر اصلی ساعت کاری فروشگاه. روشن: خرید و فروش فقط بین «ساعت شروع» و «ساعت پایان» انجام می‌شود. نمایش قیمت صفحه اصلی در خارج از این بازه با سوییچ «نمایش قیمت صفحه اصلی خارج از ساعت کاری» کنترل می‌شود. خاموش: محدودیت ساعتی سراسری اعمال نمی‌شود (مگر برای محصولی ساعت اختصاصی تعریف کرده باشید).',
startDay:
'ساعت شروع فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. از ساعت‌شمار انتخاب کنید (مثلاً ۰۹:۰۰).',
endDay:
'ساعت پایان فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. بعد از این ساعت فروشگاه بسته می‌شود (مثلاً ۱۷:۰۰ یا ۲۲:۰۰).',
show_last_price:
'رفتار هنگام در دسترس نبودن قیمت زنده. روشن: آخرین قیمت ذخیره‌شده نشان داده می‌شود. خاموش: به‌جای صفر پیام «مظنه در دسترس نیست» نمایش داده می‌شود.',
show_home_price_when_closed:
'روشن: وقتی فروشگاه خارج از ساعت کاری است، قیمت صفحه اصلی اپ همچنان نمایش داده می‌شود و پیام «فروشگاه بسته است» نشان داده نمی‌شود. خرید/فروش همچنان بسته می‌ماند. خاموش (پیش‌فرض): خارج از ساعت پیام بسته بودن نشان داده می‌شود.',
show_gold_ounce_price:
'روشن: اگر آدرس API تابلو (zaarbox_api_url) تنظیم شده باشد، قیمت انس طلا در اپ مشتری نمایش داده می‌شود. خاموش: نمایش داده نمی‌شود.',
fee_single:
@@ -785,7 +788,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
price_channel_messengers:
'کدام پیام‌رسان‌ها قیمت را دریافت کنند؟ می‌توانید چند مورد را انتخاب کنید (بله، تلگرام، روبیکا). برای هر پیام‌رسان باید توکن بات و «مقصد ارسال» هم پر شده باشد.',
price_channel_diff:
'اسپرد اضافه فقط برای کانال (ریال): به قیمت خرید کانال اضافه و از قیمت فروش کم می‌شود. صفر یعنی همان قیمت اپ.',
'اسپرد اضافه فقط برای کانال (ریال): به قیمت خرید کانال اضافه و از قیمت فروش کم می‌شود. صفر یعنی همان قیمت صفحه اصلی اپلیکیشن.',
price_channel_min_seconds:
'حداقل فاصله (ثانیه) بین دو ارسال متوالی به کانال. جلوی سیل پیام هنگام نوسان قیمت را می‌گیرد. صفر = هر تغییر قیمت ارسال شود.',
price_channel_footer_id:
@@ -814,6 +817,7 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
startDay: 'ساعت شروع',
endDay: 'ساعت پایان',
show_last_price: 'نمایش آخرین قیمت ذخیره‌شده',
show_home_price_when_closed: 'نمایش قیمت صفحه اصلی خارج از ساعت کاری',
show_gold_ounce_price: 'نمایش قیمت انس طلا',
fee_single: 'اختلاف پایه خرید/فروش',
buy_fee: 'اضافه قیمت خرید',
@@ -974,7 +978,7 @@ export const SETTING_EDITABLE_KEYS = new Set([
'open_register',
'referral_id', 'referral_limit', 'referral_gift_on_first_order',
'register_jibit_verify', 'register_referral_bonus_message', 'register_referral_sms_to_referrer',
'sell_fee', 'show_last_price', 'show_gold_ounce_price', 'startDay',
'sell_fee', 'show_last_price', 'show_home_price_when_closed', 'show_gold_ounce_price', 'startDay',
'support_messengers', 'support_messenger_text',
'deposit_support_message_text', 'deposit_account_holder', 'deposit_card_number', 'deposit_sheba',
'text_about', 'text_rule',
@@ -42,8 +42,8 @@ export function resolveOrderFeeSourceLabel(order: Order): string {
if (order.fee_source === 'app+product') {
return order.direction === 2
? 'قیمت اپ (sell_fee) + تب اختلاف محصول'
: 'قیمت اپ (buy_fee) + تب اختلاف محصول'
? 'قیمت صفحه اصلی اپلیکیشن (sell_fee) + تب اختلاف محصول'
: 'قیمت صفحه اصلی اپلیکیشن (buy_fee) + تب اختلاف محصول'
}
if (order.fee_source === 'settings') {
const key = order.fee_source_key || (order.direction === 2 ? 'sell_fee' : 'buy_fee')
@@ -23,7 +23,7 @@
<DashboardLabelWithHelp label="مرجع قیمت" :help="meltedPriceSourceHelp" size="sm" />
</div>
<a-radio-group :value="meltedPriceOption" name="meltedPriceGroup" @update:value="$emit('update:meltedPriceOption', $event)">
<a-radio value="app">قیمت اپ</a-radio>
<a-radio value="app">قیمت صفحه اصلی اپلیکیشن</a-radio>
<a-radio value="fixed">ثابت</a-radio>
<a-radio value="reference">مرجع متفاوت</a-radio>
</a-radio-group>
@@ -70,7 +70,7 @@
<div v-if="meltedPriceOption === 'app'" class="grid gap-4 md:grid-cols-3">
<a-form-item name="price_offset_percent" class="!mb-0">
<template #label>
<DashboardLabelWithHelp label="تعدیل قیمت اپ (٪)" :help="appPriceOffsetHelp" size="sm" />
<DashboardLabelWithHelp label="تعدیل قیمت صفحه اصلی اپلیکیشن (٪)" :help="appPriceOffsetHelp" size="sm" />
</template>
<BasicInput type="number" v-model:value="form.price_offset_percent" placeholder="مثلاً 5 یا -3" />
</a-form-item>
@@ -91,7 +91,7 @@
<a-radio-group :value="methodOption" name="radioGroup" @update:value="$emit('update:methodOption', $event)">
<a-radio value="1">دستی</a-radio>
<a-radio value="2">اتوماتیک</a-radio>
<a-radio value="3">قیمت اپ</a-radio>
<a-radio value="3">قیمت صفحه اصلی اپلیکیشن</a-radio>
</a-radio-group>
</div>
<div v-if="methodOption === '1'" class="grid gap-4 md:grid-cols-3">
@@ -145,7 +145,7 @@
<div class="grid gap-4 md:grid-cols-3">
<a-form-item name="price_offset_percent" class="!mb-0">
<template #label>
<DashboardLabelWithHelp label="تعدیل قیمت اپ (٪)" :help="appPriceOffsetHelp" size="sm" />
<DashboardLabelWithHelp label="تعدیل قیمت صفحه اصلی اپلیکیشن (٪)" :help="appPriceOffsetHelp" size="sm" />
</template>
<BasicInput type="number" v-model:value="form.price_offset_percent" placeholder="مثلاً 5 یا -3" />
</a-form-item>
@@ -496,11 +496,11 @@ import { computed, onMounted, ref, watch } from 'vue'
const priceSourceHelp: DashboardHelp = {
description:
'دستی: خودتان قیمت را وارد می‌کنید. اتوماتیک: قیمت از مرجع بیرونی (zaarbox) خوانده می‌شود. قیمت اپ: از قیمت زندهٔ صفحهٔ اصلی اپ بر اساس وزن محاسبه می‌شود (هر گرم × وزن محصول).',
'دستی: خودتان قیمت را وارد می‌کنید. اتوماتیک: قیمت از مرجع بیرونی (zaarbox) خوانده می‌شود. قیمت صفحه اصلی اپلیکیشن: از قیمت زندهٔ صفحهٔ اصلی اپ بر اساس وزن محاسبه می‌شود (هر گرم × وزن محصول).',
}
const meltedPriceSourceHelp: DashboardHelp = {
description: 'قیمت اپ: از صفحه اصلی اپ. ثابت: عدد دستی. مرجع متفاوت: انتخاب منبع دیگر.',
description: 'قیمت صفحه اصلی اپلیکیشن: از صفحه اصلی اپ. ثابت: عدد دستی. مرجع متفاوت: انتخاب منبع دیگر.',
}
const manualPriceHelp: DashboardHelp = {
@@ -588,8 +588,8 @@ const referencePriceOffsetHelp: DashboardHelp = {
}
const appPriceOffsetHelp: DashboardHelp = {
description: 'قیمت خرید/فروش از قیمت صفحه اصلی اپ محاسبه می‌شود.',
formula: 'مثبت = گران‌تر، منفی = ارزان‌تر نسبت به قیمت اپ',
description: 'قیمت خرید/فروش از قیمت صفحه اصلی اپلیکیشن محاسبه می‌شود.',
formula: 'مثبت = گران‌تر، منفی = ارزان‌تر نسبت به قیمت صفحه اصلی اپلیکیشن',
}
const useLastPriceIfZeroHelp: DashboardHelp = {
+1 -1
View File
@@ -192,7 +192,7 @@ export function computeProductEditorPricePreview(input: {
source: 'آخرین قیمت ذخیره‌شده × وزن'
}
}
return { status: 'empty', message: 'قیمت اپ در دسترس نیست' }
return { status: 'empty', message: 'قیمت صفحه اصلی اپلیکیشن در دسترس نیست' }
}
if (!input.form.reading_method) {