Files
interview-demo-code/frontend/admin/src/views/CustomersView.vue
Nikita Kiselev 01458e3b4c
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
Squashed commit message
2026-03-11 22:33:34 +03:00

707 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>