feat: добавлена функциональность политики конфиденциальности и согласия на обработку ПД

Основные изменения:

Backend:
- Добавлена миграция для поля privacy_consented_at в таблицу telecart_customers
- Создан PrivacyPolicyHandler с методами:
  * checkIsUserPrivacyConsented - проверка наличия согласия пользователя
  * userPrivacyConsent - сохранение согласия пользователя
- Обновлен TelegramService для извлечения userId из initData
- Обновлен TelegramServiceProvider для внедрения зависимостей
- Добавлены новые маршруты в routes.php
- Обновлен SettingsHandler для возврата privacy_policy_link
- Обновлен TelegramCustomersHandler для включения privacy_consented_at в ответы
- Обновлены тесты TelegramServiceTest

Frontend (SPA):
- Создан компонент PrivacyPolicy.vue для отображения запроса согласия
- Добавлена проверка согласия при инициализации приложения (main.js)
- Обновлен App.vue для отображения компонента PrivacyPolicy
- Добавлены функции checkIsUserPrivacyConsented и userPrivacyConsent в ftch.js
- Обновлен SettingsStore для хранения privacy_policy_link и is_privacy_consented

Frontend (Admin):
- Добавлено поле privacy_policy_link в настройки (settings.js)
- Добавлена настройка ссылки на политику конфиденциальности в GeneralView.vue
- Обновлен CustomersView.vue:
  * Добавлена колонка privacy_consented_at с отображением даты согласия
  * Добавлена поддержка help-текста для колонок с иконкой вопроса и tooltip
  * Добавлены help-тексты для колонок last_seen_at, privacy_consented_at, created_at
  * Улучшено форматирование кода
This commit is contained in:
2025-11-23 23:17:21 +03:00
committed by Nikita Kiselev
parent 9a93cc7342
commit 7a5eebec91
16 changed files with 378 additions and 52 deletions

View File

@@ -17,6 +17,7 @@ export const useSettingsStore = defineStore('settings', {
theme_light: 'light',
theme_dark: 'dark',
app_debug: false,
privacy_policy_link: null,
},
telegram: {

View File

@@ -67,13 +67,16 @@
</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"/>
<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" />
<InputIcon class="fa fa-search"/>
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..."
@input="onGlobalSearch"/>
</IconField>
</div>
</template>
@@ -91,10 +94,25 @@
</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">
<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 === '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">
@@ -132,25 +150,35 @@
</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" />
<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" />
<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" />
<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" />
<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" />
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
</template>
<template v-else-if="col.field === 'is_premium'">
<Dropdown
@@ -204,7 +232,8 @@
<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;">
<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>
@@ -245,8 +274,8 @@
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { FilterMatchMode, FilterOperator } from '@primevue/core/api';
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';
@@ -257,8 +286,8 @@ 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';
import {apiPost} from '@/utils/http.js';
import {IconField, InputIcon, useToast} from 'primevue';
const toast = useToast();
const customers = ref([]);
@@ -270,16 +299,59 @@ 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 },
{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,
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));
@@ -314,9 +386,9 @@ function deselectAllColumns() {
}
const premiumFilterOptions = [
{ label: 'Любой', value: null },
{ label: 'Нет', value: false },
{ label: 'Да', value: true },
{label: 'Любой', value: null},
{label: 'Нет', value: false},
{label: 'Да', value: true},
];
const lazyParams = ref({
@@ -329,14 +401,35 @@ const lazyParams = ref({
});
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 }] },
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) {
@@ -447,14 +540,35 @@ function onGlobalSearch() {
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 }] },
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;

View File

@@ -18,6 +18,17 @@
Изображение, которое будет отображаться в Telegram Mini App.
</ItemImage>
<ItemInput label="Политика конфиденциальности"
v-model="settings.items.app.privacy_policy_link"
placeholder="https://your-site/privacy-polisy"
>
Укажите ссылку на страницу с вашей Политикой конфиденциальности.
Она будет показываться пользователям для получения согласия на обработку персональных данных.
Вы сможете видеть, кто согласился, на странице с
<RouterLink to="/customers">Telegram покупателями</RouterLink>
.
</ItemInput>
<ItemSelect label="Светлая тема" v-model="settings.items.app.theme_light" :items="themes">
Выберите стиль, который будет использоваться при отображении вашего магазина
в Telegram для дневного режима.