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
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:
180
frontend/admin/src/views/AcmeShopPulseView.vue
Normal file
180
frontend/admin/src/views/AcmeShopPulseView.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="tw:space-y-6">
|
||||
<div class="acmeshop-pulse-info">
|
||||
<h3>🚀 Расширьте возможности вашего магазина с <strong><a href="https://acmeshop.pro/" target="_blank">AcmeShop Pulse</a>!</strong></h3>
|
||||
|
||||
<p>
|
||||
Если вы хотите не только показывать товары в Telegram, но и активно общаться с клиентами,
|
||||
рассылать новости, акции и уведомления — для этого есть <strong>AcmeShop Pulse</strong>.
|
||||
Это <strong>SaaS-платформа с месячной подпиской</strong>, которая полностью интегрируется
|
||||
с вашим ECommerce-магазином и витриной AcmeShop.
|
||||
</p>
|
||||
|
||||
<p><strong>С AcmeShop Pulse вы сможете:</strong></p>
|
||||
|
||||
<ul>
|
||||
<li>📣 Делать массовые рассылки сообщений покупателям прямо в Telegram</li>
|
||||
<li>📊 Анализировать эффективность сообщений и взаимодействие клиентов</li>
|
||||
<li>🔗 Легко синхронизироваться с вашей витриной AcmeShop — все данные остаются в одном месте</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
🧪 Платформа <strong>AcmeShop Pulse находится на ранней стадии тестирования</strong>.
|
||||
Если вам интересно и вы хотите принять участи в тестировании интересно, свяжитесь со мной через
|
||||
<a href="https://t.me/ocstore3" target="_blank">официальную группу AcmeShop в Telegram</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsItem v-if="settings.items.pulse.api_key" label="Статистика за 7 дней">
|
||||
<template #default>
|
||||
<div v-if="stats" class="tw:space-y-4">
|
||||
<div class="tw:flex tw:gap-3 tw:max-w-2xl">
|
||||
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
|
||||
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
|
||||
<div class="tw:text-xs tw:font-medium tw:text-gray-700">В очереди</div>
|
||||
<div
|
||||
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-yellow-400 tw:to-yellow-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
|
||||
<i class="fa fa-clock-o tw:text-white tw:text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
|
||||
stats.pending
|
||||
}}
|
||||
</div>
|
||||
<div class="tw:text-xs tw:text-gray-500">Ожидают отправки</div>
|
||||
</div>
|
||||
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
|
||||
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
|
||||
<div class="tw:text-xs tw:font-medium tw:text-gray-700">Отправлено</div>
|
||||
<div
|
||||
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-green-400 tw:to-green-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
|
||||
<i class="fa fa-check-circle tw:text-white tw:text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
|
||||
stats.sent
|
||||
}}
|
||||
</div>
|
||||
<div class="tw:text-xs tw:text-gray-500">Успешно доставлено</div>
|
||||
</div>
|
||||
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
|
||||
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
|
||||
<div class="tw:text-xs tw:font-medium tw:text-gray-700">Ошибки</div>
|
||||
<div
|
||||
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-red-400 tw:to-red-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
|
||||
<i class="fa fa-exclamation-circle tw:text-white tw:text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
|
||||
stats.failed
|
||||
}}
|
||||
</div>
|
||||
<div class="tw:text-xs tw:text-gray-500">Требуют внимания</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
Статистика обновляется 1 раз в час
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<ItemInput label="API ключ"
|
||||
v-model="settings.items.pulse.api_key"
|
||||
placeholder="AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
|
||||
>
|
||||
Используется для обмена информацией по кампаниям, рассылкам, сбору метрик.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Размер пакета обработки"
|
||||
v-model.number="settings.items.pulse.batch_size"
|
||||
type="number"
|
||||
placeholder="50"
|
||||
>
|
||||
Определяет, сколько событий отправляется в AcmeShop Pulse за один запуск фоновой задачи.
|
||||
При большом значении события обрабатываются быстрее, но увеличивается нагрузка на сервер.
|
||||
При малом значении нагрузка ниже, но обработка занимает больше времени.
|
||||
Рекомендуемое значение: 50.
|
||||
</ItemInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import {apiGet} from "@/utils/http.js";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const stats = ref(null);
|
||||
|
||||
const loadStats = async () => {
|
||||
const response = await apiGet('getAcmeShopPulseStats');
|
||||
if (response.success) {
|
||||
stats.value = response.data;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.acmeshop-pulse-info {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #212529;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info h3 a {
|
||||
color: #dc3545;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info h3 a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info p {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info p strong {
|
||||
color: #212529;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info ul {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info ul li {
|
||||
color: #495057;
|
||||
position: relative;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info ul li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
147
frontend/admin/src/views/CronView.vue
Normal file
147
frontend/admin/src/views/CronView.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<SettingsItem label="Режим работы планировщика" doc-href="https://docs.acmeshop.pro/features/cron/">
|
||||
<template #default>
|
||||
<SelectButton
|
||||
v-model="settings.items.cron.mode"
|
||||
:options="cronModes"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
<div v-if="settings.items.cron.mode === 'disabled'" class="tw:text-red-600 tw:font-bold">
|
||||
Все фоновые задачи отключены.
|
||||
</div>
|
||||
<div v-else-if="settings.items.cron.mode === 'cron_job_org'">
|
||||
Задачи запускаются по вызову URL с сервиса <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>. Подходит для лёгких задач; при большом количестве товаров или тяжёлых операциях возможны таймауты.
|
||||
</div>
|
||||
<div v-else>
|
||||
Рекомендуемый режим. Использует системный планировщик задач Linux.
|
||||
</div>
|
||||
</template>
|
||||
<template #expandable>
|
||||
<p>
|
||||
<strong>Системный CRON (рекомендуется):</strong> Задачи выполняются через команду PHP в оболочке сервера (CLI), без HTTP и без ограничений по времени запроса. Не зависит от посещаемости сайта и подходит для любых объёмов данных, в том числе для тяжёлых задач и больших каталогов. Требует доступа к серверу (SSH или панель с CRON). Добавьте команду ниже в планировщик (обычно <code class="tw:px-1 tw:py-0.5 tw:bg-gray-100 tw:dark:bg-gray-800 tw:rounded">crontab -e</code>) для запуска каждые 5 минут.
|
||||
</p>
|
||||
<p>
|
||||
<strong>cron-job.org:</strong> Внешний сервис по расписанию вызывает URL вашего сайта по HTTP. Не требует доступа к серверу — удобно для shared-хостинга без CRON. Ограничения: выполнение идёт через веб-запрос, поэтому есть лимиты по времени (timeout у хостинга и у cron-job.org). <strong>Не подходит для тяжёлых сайтов</strong> (много товаров, большие каталоги, тяжёлые задачи): запрос может обрываться по таймауту, задачи не успеют завершиться. Выбирайте этот способ только если нет доступа к системному CRON и нагрузка на планировщик небольшая.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач.
|
||||
</p>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<div class="tw:relative tw:mt-4">
|
||||
<div
|
||||
:class="[
|
||||
'tw:transition-all tw:duration-200',
|
||||
settings.items.cron.mode === 'disabled'
|
||||
? 'tw:blur-[2px] tw:pointer-events-none tw:select-none'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<SettingsItem label="Последний запуск CRON">
|
||||
<template #default>
|
||||
<div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2">
|
||||
{{ lastRunDate }}
|
||||
</div>
|
||||
<div v-else class="tw:text-gray-500 tw:py-2">
|
||||
Еще не запускался
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
Время последнего успешного выполнения планировщика задач.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
v-if="settings.items.cron.mode === 'system'"
|
||||
label="Команда для CRON"
|
||||
>
|
||||
<template #default>
|
||||
<InputGroup>
|
||||
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard(cronCommand)"/>
|
||||
<InputText readonly :model-value="cronCommand" class="tw:w-full"/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
<template #help>
|
||||
Добавьте эту строку в конфигурацию CRON на вашем сервере (обычно `crontab -e`), чтобы запускать планировщик каждые
|
||||
5 минут.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<CronJobOrgUrlField v-if="settings.items.cron.mode === 'cron_job_org'"/>
|
||||
|
||||
<SettingsItem label="Задачи планировщика">
|
||||
<template #default>
|
||||
<ScheduledJobsList />
|
||||
</template>
|
||||
<template #help>
|
||||
Включение и отключение задач планировщика. Дата последнего успешного запуска; при ошибке отображается иконка с подсказкой.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settings.items.cron.mode === 'disabled'"
|
||||
class="tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:rounded-lg tw:bg-white/80 tw:dark:bg-gray-900/80 tw:z-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="tw:text-lg tw:font-semibold tw:text-gray-600 tw:dark:text-gray-400">
|
||||
Планировщик выключен
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import SettingsItem from '@/components/SettingsItem.vue';
|
||||
import ScheduledJobsList from '@/components/ScheduledJobsList.vue';
|
||||
import CronJobOrgUrlField from '@/components/CronJobOrgUrlField.vue';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import { toastBus } from '@/utils/toastHelper.js';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const cronModes = [
|
||||
{value: 'system', label: 'Системный CRON (Linux)'},
|
||||
{value: 'cron_job_org', label: 'cron-job.org'},
|
||||
{value: 'disabled', label: 'Выключено'},
|
||||
];
|
||||
|
||||
const cronCommand = computed(() => {
|
||||
const cliPath = settings.items.cron?.cli_path;
|
||||
|
||||
return cliPath
|
||||
? `*/5 * * * * php ${cliPath} schedule:run`
|
||||
: 'Путь не определен. Проверьте конфигурацию модуля.';
|
||||
});
|
||||
|
||||
const lastRunDate = computed(() => settings.items.cron?.last_run);
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toastBus.emit('show', {
|
||||
severity: 'success',
|
||||
summary: 'Скопировано',
|
||||
detail: 'Команда скопирована в буфер обмена',
|
||||
life: 2000,
|
||||
});
|
||||
} catch (err) {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось скопировать текст',
|
||||
life: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
706
frontend/admin/src/views/CustomersView.vue
Normal file
706
frontend/admin/src/views/CustomersView.vue
Normal 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>
|
||||
|
||||
|
||||
23
frontend/admin/src/views/FormBuilderView.vue
Normal file
23
frontend/admin/src/views/FormBuilderView.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div v-if="isLoading" class="tw:flex tw:justify-center tw:items-center tw:h-full">
|
||||
<i class="fa fa-spinner fa-spin tw:text-4xl tw:text-blue-500"></i>
|
||||
</div>
|
||||
<div v-else class="tw:h-full">
|
||||
<FormBuilder
|
||||
v-model="settings.items.forms.checkout.schema"
|
||||
v-model:isCustom="settings.items.forms.checkout.is_custom"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue';
|
||||
import FormBuilder from '@/components/FormBuilder/FormBuilder.vue';
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
|
||||
const formSchema = ref([]);
|
||||
const isCustom = ref(false);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
</script>
|
||||
97
frontend/admin/src/views/GeneralView.vue
Normal file
97
frontend/admin/src/views/GeneralView.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<ItemBool label="Статус" v-model="settings.items.app.app_enabled">
|
||||
Если выключено, покупатели в Telegram увидят сообщение, что магазин временно закрыт.
|
||||
Заказы и просмотр товаров будут недоступны.
|
||||
</ItemBool>
|
||||
|
||||
<ItemInput label="Название приложения"
|
||||
v-model="settings.items.app.app_name"
|
||||
placeholder="Без названия"
|
||||
>
|
||||
Отображается в заголовке Telegram Mini App при запуске, а также используется как подпись
|
||||
под иконкой, если пользователь добавит приложение на главный экран своего устройства.
|
||||
Рекомендуется короткое и понятное название (до 20 символов).
|
||||
Если оставить пустым, то название выводиться не будет.
|
||||
</ItemInput>
|
||||
|
||||
<ItemImage label="Иконка приложения" v-model="settings.items.app.app_icon">
|
||||
Изображение, которое будет отображаться в 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 для дневного режима.
|
||||
<a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">
|
||||
Посмотреть как выглядят темы
|
||||
</a>
|
||||
</ItemSelect>
|
||||
|
||||
<ItemSelect label="Тёмная тема" v-model="settings.items.app.theme_dark" :items="themes">
|
||||
Выберите стиль, который будет использоваться при отображении вашего магазина
|
||||
в Telegram для ночного режима.
|
||||
<a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">
|
||||
Посмотреть как выглядят темы
|
||||
</a>
|
||||
</ItemSelect>
|
||||
|
||||
<ItemBool label="Режим разработчика" v-model="settings.items.app.app_debug">
|
||||
Режим разработчика. Рекомендуется включать только по необходимости.
|
||||
В остальных случаях, для нормальной работы магазина, должен быть выключен.
|
||||
</ItemBool>
|
||||
|
||||
<ItemSelect label="Соотношение сторон" v-model="settings.items.app.image_aspect_ratio" :items="aspectRatioOptions">
|
||||
Выберите соотношение сторон для изображений товаров. Это глобальная настройка, которая будет применяться ко всем изображениям в списках товаров: карусель товаров, лента товаров, результаты поиска.
|
||||
</ItemSelect>
|
||||
|
||||
<ItemSelect label="Алгоритм обрезки" v-model="settings.items.app.image_crop_algorithm" :items="cropAlgorithmOptions">
|
||||
Выберите алгоритм обрезки изображений. Эта настройка применяется глобально ко всем изображениям в списках товаров (карусель товаров, лента товаров, результаты поиска):
|
||||
<ul class="tw:list-disc tw:ml-5 tw:mt-2">
|
||||
<li><strong>Cover</strong> - обрезает изображение, сохраняя пропорции, чтобы заполнить весь размер (может обрезать края)</li>
|
||||
<li><strong>Contain</strong> - вписывает изображение в размер, сохраняя пропорции (может добавить пустые поля)</li>
|
||||
<li><strong>Resize</strong> - изменяет размер изображения с сохранением пропорций (без обрезки)</li>
|
||||
</ul>
|
||||
</ItemSelect>
|
||||
|
||||
<ItemBool label="Тактильная обратная связь (Haptic Feedback)" v-model="settings.items.app.haptic_enabled">
|
||||
Включить виброотклик при взаимодействии с элементами интерфейса. Если выключено, тактильная обратная связь не будет использоваться.
|
||||
</ItemBool>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemBool from "@/components/Settings/ItemBool.vue";
|
||||
import ItemImage from "@/components/Settings/ItemImage.vue";
|
||||
import ItemSelect from "@/components/Settings/ItemSelect.vue";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const themes = JSON.parse(window.AcmeShop.themes);
|
||||
|
||||
const aspectRatioOptions = {
|
||||
'1:1': '1:1 - Квадрат (универсально, аксессуары, мелкие товары)',
|
||||
'4:5': '4:5 - Вертикальное (одежда, обувь, вертикальные товары)',
|
||||
'3:4': '3:4 - Вертикальное (одежда, обувь, вертикальные товары)',
|
||||
'2:3': '2:3 - Высокое вертикальное (цветы, высокие предметы)',
|
||||
};
|
||||
|
||||
const cropAlgorithmOptions = {
|
||||
'cover': 'Cover - Обрезать с сохранением пропорций',
|
||||
'contain': 'Contain - Вписать с сохранением пропорций',
|
||||
'resize': 'Resize - Изменить размер с сохранением пропорций',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
8
frontend/admin/src/views/LogsView.vue
Normal file
8
frontend/admin/src/views/LogsView.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<LogsViewer/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import LogsViewer from "@/components/LogsViewer.vue";
|
||||
</script>
|
||||
7
frontend/admin/src/views/MainPageView.vue
Normal file
7
frontend/admin/src/views/MainPageView.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<MainPageConfigurator/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MainPageConfigurator from "@/components/MainPageConfigurator/MainPageConfigurator.vue";
|
||||
</script>
|
||||
53
frontend/admin/src/views/MetricsView.vue
Normal file
53
frontend/admin/src/views/MetricsView.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<ItemBool
|
||||
label="Яндекс.Метрика"
|
||||
v-model="settings.items.metrics.yandex_metrika_enabled"
|
||||
>
|
||||
Задействовать Яндекс.Метрику для Telegram магазина.
|
||||
</ItemBool>
|
||||
|
||||
<ItemInput
|
||||
label="Номер счётчика Яндекс.Метрика"
|
||||
v-model="settings.items.metrics.yandex_metrika_counter"
|
||||
placeholder="Вставьте код счётчика Яндекс.Метрики"
|
||||
>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
v-if="settings.items.metrics.yandex_metrika_enabled && settings.items.metrics.yandex_metrika_counter"
|
||||
as="a"
|
||||
:href="ymCheckUrl"
|
||||
target="_blank"
|
||||
variant="text"
|
||||
:disabled="settings.items.app.app_debug === false"
|
||||
v-tooltip.top="'Чтобы проверить интеграцию, включите режим разработчика на вкладке Общие и сохраните настройки.'"
|
||||
>
|
||||
Проверить интеграцию
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
as="a"
|
||||
href="https://acme-inc.github.io/docs/analitycs/start/"
|
||||
target="_blank"
|
||||
variant="text"
|
||||
>
|
||||
Как получить номер счётчика <i class="fa fa-external-link"></i>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
</ItemInput>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemBool from "@/components/Settings/ItemBool.vue";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import {Button, ButtonGroup} from 'primevue';
|
||||
import {computed} from "vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const ymCheckUrl = computed(() => {
|
||||
const url = settings.items.telegram.mini_app_url.replace(/#\/$/, '');
|
||||
return `${url}?_ym_status-check=${settings.items.metrics.yandex_metrika_counter}&_ym_lang=ru`;
|
||||
});
|
||||
</script>
|
||||
17
frontend/admin/src/views/OrdersView.vue
Normal file
17
frontend/admin/src/views/OrdersView.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<ItemSelect
|
||||
label="Статус заказов"
|
||||
v-model="settings.items.orders.order_default_status_id"
|
||||
:items="orderStatuses"
|
||||
>
|
||||
Статус, с которым будут создаваться заказы через Telegram по умолчанию.
|
||||
</ItemSelect>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemSelect from "@/components/Settings/ItemSelect.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const orderStatuses = JSON.parse(window.AcmeShop.order_statuses);
|
||||
</script>
|
||||
65
frontend/admin/src/views/StoreView.vue
Normal file
65
frontend/admin/src/views/StoreView.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<ItemToggleButton
|
||||
label="Сценарий взаимодействия с товаром"
|
||||
v-model="settings.items.store.product_interaction_mode"
|
||||
:items="productInteractionOptions"
|
||||
>
|
||||
<p>Выберите, что будет происходить при нажатии на кнопку товара:
|
||||
<br><strong>Создание заявки / заказа</strong> — Пользователи смогут добавить товар и оформить заявку на покупку прямо в Telegram. Заказ фиксируется в ECommerce, а дальнейшая работа с клиентом происходит вручную.
|
||||
<br><strong>Кнопка связи с менеджером</strong> — пользователи увидят кнопку для связи с менеджером в Telegram. Менеджера можно указать в поле "Username менеджера" ниже.
|
||||
<br><strong>Открытие товара на сайте</strong> — кнопка откроет страницу товара на основном сайте ECommerce во внешнем браузере.</p>
|
||||
</ItemToggleButton>
|
||||
|
||||
<ItemInput
|
||||
label="Username менеджера"
|
||||
v-model="settings.items.store.manager_username"
|
||||
placeholder="@username"
|
||||
>
|
||||
<p>Укажите username (например, @username) для связи с менеджером. Это может быть личный аккаунт или группа, куда покупатели могут писать. Используется только при выборе режима "Кнопка связи с менеджером".</p>
|
||||
</ItemInput>
|
||||
|
||||
<ItemBool label="Промокоды" v-model="settings.items.store.feature_coupons">
|
||||
<p>
|
||||
Позволяет использовать стандартные
|
||||
<a :href="`/admin/index.php?route=marketing/coupon&user_token=${userToken}`"
|
||||
target="_blank">купоны ECommerce</a>
|
||||
для предоставления скидок при оформлении заказа.</p>
|
||||
</ItemBool>
|
||||
|
||||
<ItemBool label="Подарочные сертификаты" v-model="settings.items.store.feature_vouchers">
|
||||
<p>
|
||||
Позволяет использовать стандартные
|
||||
<a :href="`/admin/index.php?route=sale/voucher&user_token=${userToken}`"
|
||||
target="_blank">подарочные сертификаты ECommerce</a> при оформлении заказа.</p>
|
||||
</ItemBool>
|
||||
|
||||
<ItemBool label="Показывать кнопку «Показать товары из текущей категории»" v-model="settings.items.store.show_category_products_button">
|
||||
<p>Включите, чтобы пользователи видели кнопку «Показать товары из "название текущей категории"» на странице категории, если у неё есть дочерние категории. Настройка работает только для страниц категорий с дочерними категориями, при отключении кнопка скрыта.</p>
|
||||
</ItemBool>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemBool from "@/components/Settings/ItemBool.vue";
|
||||
import ItemSelect from "@/components/Settings/ItemSelect.vue";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import ItemToggleButton from "@/components/Settings/ItemToggleButton.vue";
|
||||
import ItemProductsSelect from "@/components/Settings/ItemProductsSelect.vue";
|
||||
import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const mainpage_categories_options = {
|
||||
no_categories: 'Отображать только кнопку "Каталог"',
|
||||
latest10: 'Последние 10 категорий',
|
||||
featured: 'Избранные категории (задать в поле ниже)',
|
||||
};
|
||||
|
||||
const productInteractionOptions = {
|
||||
order: 'Создание заявки / заказа',
|
||||
manager: 'Кнопка связи с менеджером',
|
||||
browser: 'Открытие товара на сайте',
|
||||
};
|
||||
|
||||
const userToken = window.AcmeShop.user_token;
|
||||
</script>
|
||||
28
frontend/admin/src/views/TelegramView.vue
Normal file
28
frontend/admin/src/views/TelegramView.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<ItemTgMiniAppLink label="Ссылка на Telegram Mini App"
|
||||
v-model="settings.items.telegram.mini_app_url"/>
|
||||
<ItemTgBotToken label="Telegram Bot Token" v-model="settings.items.telegram.bot_token"/>
|
||||
<ItemTgChatID label="Telegram ChatID" v-model="settings.items.telegram.chat_id"/>
|
||||
<ItemTgMessageTemplate
|
||||
label="Шаблон уведомления о новом заказе владельцу"
|
||||
v-model="settings.items.telegram.owner_notification_template"
|
||||
>
|
||||
Введите шаблон сообщения для Telegram-уведомлений о новом заказе владельцу магазина.
|
||||
</ItemTgMessageTemplate>
|
||||
<ItemTgMessageTemplate
|
||||
label="Шаблон уведомления о новом заказе покупателю"
|
||||
v-model="settings.items.telegram.customer_notification_template"
|
||||
>
|
||||
Введите шаблон сообщения для Telegram-уведомлений о новом заказе покупателю.
|
||||
</ItemTgMessageTemplate>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemTgMiniAppLink from "@/components/Settings/ItemTgMiniAppLink.vue";
|
||||
import ItemTgBotToken from "@/components/Settings/ItemTgBotToken.vue";
|
||||
import ItemTgChatID from "@/components/Settings/ItemTgChatID.vue";
|
||||
import ItemTgMessageTemplate from "@/components/Settings/ItemTgMessageTemplate.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
</script>
|
||||
46
frontend/admin/src/views/TextsView.vue
Normal file
46
frontend/admin/src/views/TextsView.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<ItemInput label="Текст в конце списка товаров" v-model="settings.items.texts.text_no_more_products">
|
||||
Текст, отображаемый в конце списка, когда больше нет доступных товаров.
|
||||
Покупатель дошел до конца списка.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст пустой корзины" v-model="settings.items.texts.text_empty_cart">
|
||||
Текст, отображаемый на странице просмотра корзины, если в ней нет товаров.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст для успешного заказа" v-model="settings.items.texts.text_order_created_success">
|
||||
Текст, отображаемый при успешном создании заказа.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст вместо нулевой цены" v-model="settings.items.texts.zero_price_text" placeholder="0.00р.">
|
||||
Текст, который будет выводиться вместо цены, в случае если цена = 0.
|
||||
Если текст отсутствует, то будет выводиться нулевая цена по умолчанию.
|
||||
</ItemInput>
|
||||
|
||||
<ItemTextarea label="Приветственный текст" v-model="settings.items.texts.start_message" placeholder="Например, добро пожаловать в наш магазин.">
|
||||
Сообщение, которое выводится в приветственном сообщении покупателю (когда он
|
||||
запустит бота через `/start`). Можно использовать HTML разметку, которую
|
||||
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">
|
||||
поддерживает Telegram <i class="fa fa-external-link"></i>
|
||||
</a>. Можно использовать <a href="https://getemoji.com/" target="_blank">
|
||||
эмодзи <i class="fa fa-external-link"></i>
|
||||
</a>.
|
||||
</ItemTextarea>
|
||||
|
||||
<ItemInput label="Текст кнопки приветственного сообщения" v-model="settings.items.texts.start_button.text">
|
||||
Текст на кнопке приветственного сообщения, которая открывает магазин.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст кнопки связи с менеджером" v-model="settings.items.texts.text_manager_button" placeholder="Связаться с менеджером">
|
||||
Текст на кнопке для связи с менеджером на странице товара. Используется только при выборе режима "Кнопка связи с менеджером" в настройках витрины.
|
||||
</ItemInput>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import ItemImage from "@/components/Settings/ItemImage.vue";
|
||||
import ItemTextarea from "@/components/Settings/ItemTextarea.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
</script>
|
||||
Reference in New Issue
Block a user