Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled

This commit is contained in:
2026-03-11 22:08:41 +03:00
commit 01458e3b4c
589 changed files with 65788 additions and 0 deletions

View File

@@ -0,0 +1,706 @@
<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"
:sortable="col.sortable" :dataType="col.dataType"
:showFilterMenu="col.filterable !== false">
<template #header>
<div class="tw:flex tw:items-center tw:gap-1">
<span>{{ col.header }}</span>
<i
v-if="col.help"
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help"
v-tooltip.top="col.help"
></i>
</div>
</template>
<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 === 'tracking_id'">
<code>{{ data.tracking_id }}</code>
</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 === 'orders_count'">
<span>{{ data.orders_count }}</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 === 'privacy_consented_at'">
<span v-if="data.privacy_consented_at">
<i class="fa fa-clock-o"></i> {{ formatDate(data.privacy_consented_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', 'privacy_consented_at'].includes(col.field)">
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
</template>
<template v-else-if="col.field === 'orders_count'">
<InputNumber v-model="filterModel.value"/>
</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 {onMounted, ref} 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 InputNumber from 'primevue/inputnumber';
import {apiPost} from '@/utils/http.js';
import {IconField, InputIcon, useToast} 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: 'tracking_id',
header: 'Tracking ID',
sortable: false,
filterable: true,
visible: false,
help: 'Tracking ID это публичный уникальный идентификатор покупателя, используется в рекламных кампаниях для отслеживания активности.',
},
{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: 'orders_count',
header: 'Кол-во заказов',
sortable: true,
filterable: true,
dataType: 'numeric',
visible: true,
help: 'Общее количество Telegram заказов за всё время',
},
{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,
help: 'Показывает, когда пользователь последний раз открывал Telegram магазин.',
},
{
field: 'privacy_consented_at',
header: 'Согласие ПД',
sortable: true,
dataType: 'date',
filterable: true,
visible: true,
help: 'Показывает, когда пользователь дал согласие на обработку персональных данных.',
},
{
field: 'created_at',
header: 'Дата регистрации',
sortable: true,
dataType: 'date',
filterable: true,
visible: true,
help: 'Показывает, когда пользователь впервые открыл Telegram магазин.',
},
]);
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}]
},
orders_count: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}],
},
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}]
},
privacy_consented_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', 'privacy_consented_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}]
},
orders_count: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
},
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}]
},
privacy_consented_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>