@@ -0,0 +1,549 @@
< template >
< div >
< DataTable
:value = "customers"
:loading = "loading"
paginator
:rows = "20"
: rowsPerPageOptions = "[10, 20, 50, 100]"
:sortField = "lazyParams.sortField"
:sortOrder = "lazyParams.sortOrder"
showGridlines
stripedRows
size = "small"
removableSort
: globalFilterFields = "['telegram_user_id', 'username', 'first_name', 'last_name', 'language_code']"
v -model :filters = "filters"
filterDisplay = "menu"
:lazy = "true"
:totalRecords = "totalRecords"
@page ="onPage"
@sort ="onSort"
@filter ="onFilter"
paginatorTemplate = "FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
: currentPageReportTemplate = "`Показано {first} - {last} из {totalRecords} записей`"
>
< template # header >
< div class = "tw:flex tw:flex-wrap tw:items-center tw:justify-between tw:gap-2" >
< div class = "tw:flex tw:items-center tw:gap-2" >
< Button
icon = "fa fa-columns"
: label = "`Колонки (${selectedColumns.length}/${columns.length})`"
@click ="toggleColumnsPanel"
size = "small"
/ >
< OverlayPanel ref = "columnsPanel" >
< div class = "tw:flex tw:flex-col tw:gap-2 tw:min-w-[200px]" >
< div class = "tw:flex tw:gap-2 tw:mb-2" >
< Button
label = "Выбрать все"
size = "small"
severity = "secondary"
outlined
@click ="selectAllColumns"
class = "tw:flex-1"
/ >
< Button
label = "Снять все"
size = "small"
severity = "secondary"
outlined
@click ="deselectAllColumns"
class = "tw:flex-1"
/ >
< / div >
< div
v-for = "col in columns"
:key = "col.field"
class = "tw:flex tw:items-center tw:gap-2"
>
< Checkbox
:inputId = "col.field"
: modelValue = "selectedColumns.some(c => c.field === col.field)"
@ update : modelValue = "(val) => toggleColumn(col, val)"
:binary = "true"
/ >
< label :for = "col.field" class = "tw:cursor-pointer" > { { col . header } } < / label >
< / div >
< / div >
< / OverlayPanel >
< Button icon = "fa fa-refresh" @click ="loadCustomers" v -tooltip .top = " ' Обновить таблицу ' " size = "small" / >
< Button icon = "fa fa-times-circle" label = "Сбросить фильтры" @click ="resetFilters" v -tooltip .top = " ' Сбросить все фильтры ' " size = "small" / >
< / div >
< IconField >
< InputIcon class = "fa fa-search" / >
< InputText v-model = "globalSearchValue" placeholder="Поиск по таблице..." @input="onGlobalSearch" / >
< / IconField >
< / div >
< / template >
< Column header = "Действия" :exportable = "false" headerStyle = "width: 5rem" >
< template # body = "{ data }" >
< Button
icon = "fa fa-paper-plane"
severity = "secondary"
text
rounded
@click ="openMessageDialog(data)"
v -tooltip .top = " ' Отправить сообщение пользователю в Telegram ' "
/ >
< / template >
< / Column >
< Column v-for = "col in selectedColumns" :key="col.field" :field="col.field" :header="col.header" :sortable="col.sortable" :dataType="col.dataType" :showFilterMenu="col.filterable !== false" >
< template # body = "{ data }" >
< template v-if = "col.field === 'id'" > {{ data.id }} < / template >
< template v-else-if = "col.field === 'telegram_user_id'" > {{ data.telegram_user_id }} < / template >
< template v-else-if = "col.field === 'username'" >
< div class = "tw:flex tw:items-center tw:gap-2" >
< div v-if = "data.photo_url" class="tw:relative" >
< img
:src = "data.photo_url"
: alt = "data.username || 'Avatar'"
class = "tw:w-6 tw:h-6 tw:rounded-full tw:object-cover"
@error ="handleImageError"
/ >
< / div >
< i v-else class = "fa fa-user tw:text-gray-400" > < / i >
< span v-if = "data.username" > @ {{ data.username }} < / span >
< span v-else > — < / span >
< / div >
< / template >
< template v-else-if = "col.field === 'first_name'" > {{ data.first_name | | ' — ' }} < / template >
< template v-else-if = "col.field === 'last_name'" > {{ data.last_name | | ' — ' }} < / template >
< template v-else-if = "col.field === 'language_code'" >
< span v-if = "data.language_code" >
< i class = "fa fa-globe" > < / i > { { data . language _code . toUpperCase ( ) } }
< / span >
< span v-else > — < / span >
< / template >
< template v-else-if = "col.field === 'is_premium'" >
< i v-if = "data.is_premium" class="fa fa-star" v-tooltip.top="'Премиум пользователь'" > < / i >
< span v-else > — < / span >
< / template >
< template v-else-if = "col.field === 'oc_customer_id'" >
< span v-if = "data.oc_customer_id" > {{ data.oc_customer_id }} < / span >
< span v-else > — < / span >
< / template >
< template v-else-if = "col.field === 'last_seen_at'" >
< span v-if = "data.last_seen_at" >
< i class = "fa fa-clock-o" > < / i > { { formatDate ( data . last _seen _at ) } }
< / span >
< span v-else > — < / span >
< / template >
< template v-else-if = "col.field === 'created_at'" >
< i class = "fa fa-calendar" > < / i > { { formatDate ( data . created _at ) } }
< / template >
< / template >
< template # filter = "{ filterModel }" >
< template v-if = "col.field === 'telegram_user_id'" >
< InputText v-model = "filterModel.value" type="text" placeholder="Поиск по ID" class="p-column-filter" / >
< / template >
< template v-else-if = "col.field === 'username'" >
< InputText v-model = "filterModel.value" type="text" placeholder="Поиск по имени пользователя" class="p-column-filter" / >
< / template >
< template v-else-if = "col.field === 'first_name'" >
< InputText v-model = "filterModel.value" type="text" placeholder="Поиск по имени" class="p-column-filter" / >
< / template >
< template v-else-if = "col.field === 'last_name'" >
< InputText v-model = "filterModel.value" type="text" placeholder="Поиск по фамилии" class="p-column-filter" / >
< / template >
< template v-else-if = "['last_seen_at', 'created_at'].includes(col.field)" >
< DatePicker v-model = "filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy" / >
< / template >
< template v-else-if = "col.field === 'is_premium'" >
< Dropdown
v-model = "filterModel.value"
:options = "premiumFilterOptions"
optionLabel = "label"
optionValue = "value"
placeholder = "Любой"
class = "p-column-filter"
/ >
< / template >
< / template >
< / Column >
< template # empty >
< div style = "text-align: center; padding: 2rem;" >
< i class = "fa fa-users" style = "font-size: 3rem; color: #ccc; margin-bottom: 1rem;" > < / i >
< div > Нет данных о кастомерах < / div >
< div style = "font-size: 0.9rem; color: #999; margin-top: 0.5rem;" >
Пользователи появятся здесь после первого входа в Telegram Mini App
< / div >
< / div >
< / template >
< template # loading >
< div style = "text-align: center; padding: 2rem;" >
< i class = "fa fa-spinner fa-spin" style = "font-size: 2rem;" > < / i >
< div style = "margin-top: 1rem;" > Загрузка данных ... < / div >
< / div >
< / template >
< / DataTable >
< Dialog
v -model :visible = "showMessageDialog"
modal
header = "Отправить сообщение"
: style = "{ width: '500px' }"
:closable = "true"
>
< div style = "margin-bottom: 1rem;" >
< div style = "margin-bottom: 0.5rem; font-weight: 600;" >
Получатель :
< / div >
< div v-if = "selectedCustomer" >
< div v-if = "selectedCustomer.username" >
< i class = "fa fa-user" > < / i > @ { { selectedCustomer . username } }
< / div >
< div v-if = "selectedCustomer.first_name || selectedCustomer.last_name" >
{{ selectedCustomer.first_name }} {{ selectedCustomer.last_name }}
< / div >
< div style = "font-size: 0.9rem; color: #666;" >
ID : { { selectedCustomer . telegram _user _id } }
< / div >
< div v-if = "!selectedCustomer.allows_write_to_pm" style="color: #f59e0b; margin-top: 0.5rem;" >
< i class = "fa fa-exclamation-triangle" > < / i > Пользователь не разрешил писать ему в PM
< / div >
< / div >
< / div >
< div style = "margin-bottom: 1rem;" >
< label for = "message" style = "display: block; margin-bottom: 0.5rem; font-weight: 600;" >
Сообщение :
< / label >
< Textarea
id = "message"
v-model = "messageText"
:rows = "5"
: disabled = "!selectedCustomer || !selectedCustomer.allows_write_to_pm"
placeholder = "Введите текст сообщения..."
style = "width: 100%;"
/ >
< / div >
< template # footer >
< Button
label = "Отмена"
icon = "fa fa-times"
severity = "secondary"
@click ="closeMessageDialog"
:disabled = "sendingMessage"
/ >
< Button
label = "Отправить"
icon = "fa fa-paper-plane"
@click ="sendMessage"
:loading = "sendingMessage"
: disabled = "!messageText || !selectedCustomer || !selectedCustomer.allows_write_to_pm"
/ >
< / template >
< / Dialog >
< / div >
< / template >
< script setup >
import { ref , onMounted } from 'vue' ;
import { FilterMatchMode , FilterOperator } from '@primevue/core/api' ;
import DataTable from 'primevue/datatable' ;
import Column from 'primevue/column' ;
import DatePicker from 'primevue/datepicker' ;
import Dropdown from 'primevue/dropdown' ;
import InputText from 'primevue/inputtext' ;
import Dialog from 'primevue/dialog' ;
import Textarea from 'primevue/textarea' ;
import OverlayPanel from 'primevue/overlaypanel' ;
import Checkbox from 'primevue/checkbox' ;
import Button from 'primevue/button' ;
import { apiPost } from '@/utils/http.js' ;
import { useToast , IconField , InputIcon } from 'primevue' ;
const toast = useToast ( ) ;
const customers = ref ( [ ] ) ;
const loading = ref ( false ) ;
const totalRecords = ref ( 0 ) ;
const showMessageDialog = ref ( false ) ;
const selectedCustomer = ref ( null ) ;
const messageText = ref ( '' ) ;
const sendingMessage = ref ( false ) ;
const columns = ref ( [
{ field : 'id' , header : '№' , sortable : true , filterable : false , visible : true } ,
{ field : 'telegram_user_id' , header : 'ID в Telegram' , sortable : true , filterable : true , visible : false } ,
{ field : 'username' , header : 'Имя пользователя' , sortable : true , filterable : true , visible : true } ,
{ field : 'first_name' , header : 'Имя' , sortable : true , filterable : true , visible : true } ,
{ field : 'last_name' , header : 'Фамилия' , sortable : true , filterable : true , visible : true } ,
{ field : 'language_code' , header : 'Язык интерфейса' , sortable : true , filterable : false , visible : false } ,
{ field : 'is_premium' , header : 'Премиум статус' , sortable : true , filterable : true , visible : true } ,
{ field : 'oc_customer_id' , header : 'ID покупателя' , sortable : true , filterable : false , visible : false } ,
{ field : 'last_seen_at' , header : 'Последний визит' , sortable : true , dataType : 'date' , filterable : true , visible : true } ,
{ field : 'created_at' , header : 'Дата регистрации' , sortable : true , dataType : 'date' , filterable : true , visible : true } ,
] ) ;
const selectedColumns = ref ( columns . value . filter ( col => col . visible !== false ) ) ;
const columnsPanel = ref ( null ) ;
const globalSearchValue = ref ( '' ) ;
let searchTimeout = null ;
function toggleColumnsPanel ( event ) {
columnsPanel . value . toggle ( event ) ;
}
function toggleColumn ( col , checked ) {
// Сохраняем порядок колонок из исходного массива columns
const selectedFields = new Set ( selectedColumns . value . map ( c => c . field ) ) ;
if ( checked ) {
selectedFields . add ( col . field ) ;
} else {
selectedFields . delete ( col . field ) ;
}
// Пересоздаем массив, сохраняя исходный порядок из columns
selectedColumns . value = columns . value . filter ( c => selectedFields . has ( c . field ) ) ;
}
function selectAllColumns ( ) {
selectedColumns . value = [ ... columns . value ] ;
}
function deselectAllColumns ( ) {
selectedColumns . value = [ ] ;
}
const premiumFilterOptions = [
{ label : 'Любой' , value : null } ,
{ label : 'Нет' , value : false } ,
{ label : 'Да' , value : true } ,
] ;
const lazyParams = ref ( {
first : 0 ,
rows : 20 ,
page : 1 ,
sortField : 'last_seen_at' ,
sortOrder : - 1 ,
filters : { } ,
} ) ;
const filters = ref ( {
global : { value : null , matchMode : FilterMatchMode . CONTAINS } ,
telegram _user _id : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . STARTS _WITH } ] } ,
username : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . CONTAINS } ] } ,
first _name : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . CONTAINS } ] } ,
last _name : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . CONTAINS } ] } ,
is _premium : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . EQUALS } ] } ,
created _at : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . DATE _IS } ] } ,
last _seen _at : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . DATE _IS } ] } ,
} ) ;
function processFiltersForBackend ( filtersObj ) {
const processed = JSON . parse ( JSON . stringify ( filtersObj ) ) ;
// Обрабатываем фильтры по датам
const dateFields = [ 'created_at' , 'last_seen_at' ] ;
dateFields . forEach ( field => {
if ( processed [ field ] && processed [ field ] . constraints ) {
processed [ field ] . constraints . forEach ( constraint => {
if ( constraint . value && [ 'dateIs' , 'dateIsNot' , 'dateBefore' , 'dateAfter' ] . includes ( constraint . matchMode ) ) {
// Преобразуем дату в формат YYYY-MM-DD, используя локальное время
const date = new Date ( constraint . value ) ;
const year = date . getFullYear ( ) ;
const month = String ( date . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
const day = String ( date . getDate ( ) ) . padStart ( 2 , '0' ) ;
constraint . value = ` ${ year } - ${ month } - ${ day } ` ;
}
} ) ;
}
} ) ;
return processed ;
}
async function loadCustomers ( event = null ) {
loading . value = true ;
try {
const processedFilters = processFiltersForBackend ( filters . value ) ;
const params = {
page : lazyParams . value . page ,
rows : lazyParams . value . rows ,
sortField : lazyParams . value . sortField ,
sortOrder : lazyParams . value . sortOrder === - 1 ? 'DESC' : 'ASC' ,
filters : processedFilters ,
} ;
const result = await apiPost ( 'getTelegramCustomers' , params ) ;
if ( result . success && result . data ) {
// apiPost возвращает полный ответ сервера, а apiGet возвращает response.data.data
// Поэтому здесь нужно проверить, есть ли вложенный data
const responseData = result . data . data ? result . data . data : result . data ;
customers . value = Array . isArray ( responseData ) ? responseData : ( responseData . data || [ ] ) ;
totalRecords . value = responseData . totalRecords || 0 ;
} else {
toast . add ( {
severity : 'error' ,
summary : 'Ошибка' ,
detail : result . error || 'Н е удалось загрузить данные' ,
life : 3000 ,
} ) ;
}
} catch ( error ) {
console . error ( 'Ошибка при загрузке кастомеров:' , error ) ;
toast . add ( {
severity : 'error' ,
summary : 'Ошибка' ,
detail : 'Произошла ошибка при загрузке данных' ,
life : 3000 ,
} ) ;
} finally {
loading . value = false ;
}
}
function onPage ( event ) {
lazyParams . value . first = event . first ;
lazyParams . value . rows = event . rows ;
lazyParams . value . page = event . page + 1 ;
loadCustomers ( event ) ;
}
function onSort ( event ) {
lazyParams . value . sortField = event . sortField ;
lazyParams . value . sortOrder = event . sortOrder ;
lazyParams . value . page = 1 ;
lazyParams . value . first = 0 ;
loadCustomers ( event ) ;
}
function onFilter ( event ) {
filters . value = event . filters ;
lazyParams . value . page = 1 ;
lazyParams . value . first = 0 ;
loadCustomers ( event ) ;
}
function onGlobalSearch ( ) {
// Обновляем глобальный фильтр
filters . value . global . value = globalSearchValue . value || null ;
// Сбрасываем на первую страницу
lazyParams . value . page = 1 ;
lazyParams . value . first = 0 ;
// Debounce: очищаем предыдущий таймер
if ( searchTimeout ) {
clearTimeout ( searchTimeout ) ;
}
// Устанавливаем новый таймер для отправки запроса через 300ms после последнего ввода
searchTimeout = setTimeout ( ( ) => {
loadCustomers ( ) ;
} , 800 ) ;
}
function resetFilters ( ) {
globalSearchValue . value = '' ;
filters . value = {
global : { value : null , matchMode : FilterMatchMode . CONTAINS } ,
telegram _user _id : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . STARTS _WITH } ] } ,
username : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . CONTAINS } ] } ,
first _name : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . CONTAINS } ] } ,
last _name : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . CONTAINS } ] } ,
is _premium : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . EQUALS } ] } ,
created _at : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . DATE _IS } ] } ,
last _seen _at : { operator : FilterOperator . AND , constraints : [ { value : null , matchMode : FilterMatchMode . DATE _IS } ] } ,
} ;
lazyParams . value . page = 1 ;
lazyParams . value . first = 0 ;
loadCustomers ( ) ;
}
function handleImageError ( event ) {
// Скрываем изображение при ошибке загрузки
event . target . style . display = 'none' ;
}
function formatDate ( dateString ) {
if ( ! dateString ) return '—' ;
const date = new Date ( dateString ) ;
return date . toLocaleString ( 'ru-RU' , {
year : 'numeric' ,
month : '2-digit' ,
day : '2-digit' ,
hour : '2-digit' ,
minute : '2-digit' ,
} ) ;
}
function openMessageDialog ( customer ) {
selectedCustomer . value = customer ;
messageText . value = '' ;
showMessageDialog . value = true ;
}
function closeMessageDialog ( ) {
showMessageDialog . value = false ;
selectedCustomer . value = null ;
messageText . value = '' ;
}
async function sendMessage ( ) {
if ( ! selectedCustomer . value || ! messageText . value . trim ( ) ) {
return ;
}
if ( ! selectedCustomer . value . allows _write _to _pm ) {
toast . add ( {
severity : 'warn' ,
summary : 'Предупреждение' ,
detail : 'Пользователь не разрешил писать ему в PM' ,
life : 3000 ,
} ) ;
return ;
}
sendingMessage . value = true ;
try {
const result = await apiPost ( 'sendMessageToCustomer' , {
id : selectedCustomer . value . id ,
message : messageText . value . trim ( ) ,
} ) ;
if ( result . success && result . data ? . success ) {
toast . add ( {
severity : 'success' ,
summary : 'Успешно' ,
detail : result . data ? . message || 'Сообщение отправлено' ,
life : 3000 ,
} ) ;
closeMessageDialog ( ) ;
} else {
toast . add ( {
severity : 'error' ,
summary : 'Ошибка' ,
detail : result . data ? . error || result . error || 'Н е удалось отправить сообщение' ,
life : 3000 ,
} ) ;
}
} catch ( error ) {
console . error ( 'Ошибка при отправке сообщения:' , error ) ;
toast . add ( {
severity : 'error' ,
summary : 'Ошибка' ,
detail : 'Произошла ошибка при отправке сообщения' ,
life : 3000 ,
} ) ;
} finally {
sendingMessage . value = false ;
}
}
onMounted ( ( ) => {
loadCustomers ( ) ;
} ) ;
< / script >