feat: add Telegram customers management system with admin panel
Implement comprehensive Telegram customers storage and management functionality: Backend: - Add database migration for telecart_customers table with indexes - Create TelegramCustomer model with CRUD operations - Implement TelegramCustomerService for business logic - Add TelegramCustomerHandler for API endpoint (saveOrUpdate) - Add TelegramCustomersHandler for admin API (getCustomers with pagination, filtering, sorting) - Add SendMessageHandler for sending messages to customers via Telegram - Create custom exceptions: TelegramCustomerNotFoundException, TelegramCustomerWriteNotAllowedException - Refactor TelegramInitDataDecoder to separate decoding logic - Add TelegramHeader enum for header constants - Update SignatureValidator to use TelegramInitDataDecoder - Register new routes in bastion/routes.php and src/routes.php Frontend (Admin): - Add CustomersView.vue component with PrimeVue DataTable - Implement advanced filtering (text, date, boolean filters) - Add column visibility toggle functionality - Add global search with debounce - Implement message sending dialog with validation - Add Russian locale for PrimeVue components - Add navigation link in App.vue - Register route in router Frontend (SPA): - Add saveTelegramCustomer utility function - Integrate automatic customer data saving on app initialization - Extract user data from Telegram.WebApp.initDataUnsafe The system automatically saves/updates customer data when users access the Telegram Mini App, and provides admin interface for viewing, filtering, and messaging customers. BREAKING CHANGE: None
This commit is contained in:
@@ -34,6 +34,10 @@
|
||||
<RouterLink :to="{name: 'formbuilder'}">Форма заказа</RouterLink>
|
||||
</li>
|
||||
|
||||
<li :class="{active: route.name === 'customers'}">
|
||||
<RouterLink :to="{name: 'customers'}">Telegram Покупатели</RouterLink>
|
||||
</li>
|
||||
|
||||
<li :class="{active: route.name === 'logs'}">
|
||||
<RouterLink :to="{name: 'logs'}">Журнал событий</RouterLink>
|
||||
</li>
|
||||
|
||||
@@ -66,3 +66,11 @@ ul.formkit-options label {
|
||||
ul.formkit-options input[type="radio"] {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
input.p-checkbox-input {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,49 @@ onReady(async () => {
|
||||
options: {
|
||||
cssLayer: false, // если используешь Tailwind, отключает layering
|
||||
},
|
||||
},
|
||||
locale: {
|
||||
startsWith: 'Начинается с',
|
||||
contains: 'Содержит',
|
||||
notContains: 'Не содержит',
|
||||
endsWith: 'Заканчивается на',
|
||||
equals: 'Равно',
|
||||
notEquals: 'Не равно',
|
||||
noFilter: 'Без фильтра',
|
||||
lt: 'Меньше чем',
|
||||
lte: 'Меньше или равно',
|
||||
gt: 'Больше чем',
|
||||
gte: 'Больше или равно',
|
||||
dateIs: 'Дата равна',
|
||||
dateIsNot: 'Дата не равна',
|
||||
dateBefore: 'Дата до',
|
||||
dateAfter: 'Дата после',
|
||||
clear: 'Очистить',
|
||||
apply: 'Применить',
|
||||
matchAll: 'Совпадает со всеми',
|
||||
matchAny: 'Совпадает с любым',
|
||||
addRule: 'Добавить правило',
|
||||
removeRule: 'Удалить правило',
|
||||
accept: 'Да',
|
||||
reject: 'Нет',
|
||||
choose: 'Выбрать',
|
||||
upload: 'Загрузить',
|
||||
cancel: 'Отмена',
|
||||
dayNames: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'],
|
||||
dayNamesShort: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
|
||||
dayNamesMin: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
|
||||
monthNames: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'],
|
||||
monthNamesShort: ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'],
|
||||
today: 'Сегодня',
|
||||
weekHeader: 'Неделя',
|
||||
firstDayOfWeek: 1,
|
||||
dateFormat: 'dd.mm.yy',
|
||||
weak: 'Слабый',
|
||||
medium: 'Средний',
|
||||
strong: 'Сильный',
|
||||
passwordPrompt: 'Введите пароль',
|
||||
emptyMessage: 'Нет доступных записей',
|
||||
emptyFilterMessage: 'Нет доступных записей'
|
||||
}
|
||||
});
|
||||
app.use(ToastService);
|
||||
|
||||
@@ -8,19 +8,21 @@ import StoreView from "@/views/StoreView.vue";
|
||||
import MainPageView from "@/views/MainPageView.vue";
|
||||
import LogsView from "@/views/LogsView.vue";
|
||||
import FormBuilderView from "@/views/FormBuilderView.vue";
|
||||
import CustomersView from "@/views/CustomersView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{path: '/', name: 'general', component: GeneralView},
|
||||
{path: '/orders', name: 'orders', component: OrdersView},
|
||||
{path: '/texts', name: 'texts', component: TextsView},
|
||||
{path: '/telegram', name: 'telegram', component: TelegramView},
|
||||
{path: '/metrics', name: 'metrics', component: MetricsView},
|
||||
{path: '/store', name: 'store', component: StoreView},
|
||||
{path: '/mainpage', name: 'mainpage', component: MainPageView},
|
||||
{path: '/logs', name: 'logs', component: LogsView},
|
||||
{path: '/customers', name: 'customers', component: CustomersView},
|
||||
{path: '/formbuilder', name: 'formbuilder', component: FormBuilderView},
|
||||
{path: '/logs', name: 'logs', component: LogsView},
|
||||
{path: '/mainpage', name: 'mainpage', component: MainPageView},
|
||||
{path: '/metrics', name: 'metrics', component: MetricsView},
|
||||
{path: '/orders', name: 'orders', component: OrdersView},
|
||||
{path: '/store', name: 'store', component: StoreView},
|
||||
{path: '/telegram', name: 'telegram', component: TelegramView},
|
||||
{path: '/texts', name: 'texts', component: TextsView},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
549
frontend/admin/src/views/CustomersView.vue
Normal file
549
frontend/admin/src/views/CustomersView.vue
Normal file
@@ -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>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {useSettingsStore} from "@/stores/SettingsStore.js";
|
||||
import ApplicationError from "@/ApplicationError.vue";
|
||||
import AppMetaInitializer from "@/utils/AppMetaInitializer.ts";
|
||||
import {injectYaMetrika} from "@/utils/yaMetrika.js";
|
||||
import {saveTelegramCustomer} from "@/utils/ftch.js";
|
||||
|
||||
import {register} from 'swiper/element/bundle';
|
||||
import 'swiper/element/bundle';
|
||||
@@ -44,6 +45,20 @@ settings.load()
|
||||
throw new Error('App disabled (maintenance mode)');
|
||||
}
|
||||
})
|
||||
.then(async () => {
|
||||
// Сохраняем данные Telegram-пользователя в базу данных
|
||||
const userData = window.Telegram?.WebApp?.initDataUnsafe?.user;
|
||||
if (userData) {
|
||||
try {
|
||||
console.debug('[Init] Saving Telegram customer data');
|
||||
await saveTelegramCustomer(userData);
|
||||
console.debug('[Init] Telegram customer data saved successfully');
|
||||
} catch (error) {
|
||||
// Не прерываем загрузку приложения, если не удалось сохранить пользователя
|
||||
console.warn('[Init] Failed to save Telegram customer data:', error);
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(() => blocks.processBlocks(settings.mainpage_blocks))
|
||||
.then(async () => {
|
||||
console.debug('Load default filters for the main page');
|
||||
|
||||
@@ -96,4 +96,15 @@ export async function processBlock(block) {
|
||||
return await ftch('processBlock', null, block);
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить или обновить данные Telegram-пользователя
|
||||
* @param {Object} userData - Данные пользователя из Telegram.WebApp.initDataUnsafe.user
|
||||
* @returns {Promise}
|
||||
*/
|
||||
export async function saveTelegramCustomer(userData) {
|
||||
return await ftch('saveTelegramCustomer', null, {
|
||||
user: userData,
|
||||
});
|
||||
}
|
||||
|
||||
export default ftch;
|
||||
|
||||
Reference in New Issue
Block a user