feat: update admin page

This commit is contained in:
2025-11-03 09:20:28 +03:00
parent 30b0108fe7
commit cd818d3356
94 changed files with 4729 additions and 1227 deletions

View File

@@ -1,10 +1,77 @@
<template>
<div v-if="! settings.error" class="tw:relative">
<TopLead/>
<ul class="nav nav-tabs">
<li :class="{active: route.name === 'general'}">
<RouterLink :to="{name: 'general'}">Общие</RouterLink>
</li>
<li :class="{active: route.name === 'telegram'}">
<RouterLink :to="{name: 'telegram'}">Telegram</RouterLink>
</li>
<li :class="{active: route.name === 'metrics'}">
<RouterLink :to="{name: 'metrics'}">Метрики</RouterLink>
</li>
<li :class="{active: route.name === 'store'}">
<RouterLink :to="{name: 'store'}">Магазин</RouterLink>
</li>
<li :class="{active: route.name === 'texts'}">
<RouterLink :to="{name: 'texts'}">Тексты</RouterLink>
</li>
<li :class="{active: route.name === 'orders'}">
<RouterLink :to="{name: 'orders'}">Заказы</RouterLink>
</li>
<li :class="{active: route.name === 'slider'}">
<RouterLink :to="{name: 'slider'}">Слайдер</RouterLink>
</li>
</ul>
<section class="form-horizontal tab-content">
<RouterView/>
</section>
<section>
<Button label="Сохранить настройки" @click="settings.saveSettings"/>
</section>
<div v-if="settings.isLoading" class="tw:w-full tw:h-full tw:absolute tw:top-0 tw:left-0 tw:z-30 tw:backdrop-blur-sm">
<div class="tw:fixed tw:top-0 tw:left-0 tw:w-full tw:h-full tw:flex tw:justify-center tw:items-center tw:z-40 tw:text-4xl">
<i class="fa fa-spin fa-spinner tw:mr-5"></i>
<div>Загрузка...</div>
</div>
</div>
<Toast position="top-right"/>
</div>
<div v-else class="tw:w-full tw:h-full tw:absolute tw:top-0 tw:left-0 tw:z-30 tw:backdrop-blur-sm">
<div class="tw:fixed tw:top-0 tw:left-0 tw:w-full tw:h-full tw:flex tw:flex-col tw:justify-center tw:items-center tw:z-40">
<i class="fa fa-ban tw:text-4xl"></i>
<div class="tw:text-4xl">{{ settings.error }}</div>
<div>Обратитесь в поддержку</div>
</div>
</div>
</template>
<script setup>
import { RouterView } from 'vue-router'
import {RouterView, useRoute} from 'vue-router';
import {useSettingsStore} from "@/stores/settings.js";
import Toast from 'primevue/toast';
import { toastBus } from '@/utils/toastHelper';
import {useToast} from "primevue";
import Button from 'primevue/button';
import TopLead from "@/components/TopLead.vue";
const route = useRoute();
const settings = useSettingsStore();
const toast = useToast();
toastBus.on('show', (data) => toast.add(data));
</script>
<template>
<RouterView />
</template>
<style scoped>

View File

@@ -19,3 +19,23 @@
all: unset !important;
}
}
html {
font-size: 14px;
}
.p-toast .p-toast-message-success {
color: #3c763d;
background-color: #dff0d8;
border-color: #d6e9c6;
}
.p-toggleswitch > input[type="checkbox"] {
position: absolute;
width: 100%;
height: 100%;
border: none;
border-radius: unset;
margin: 0;
}

View File

@@ -10,6 +10,7 @@
data-placeholder="/image/cache/no_image-100x100.png"
alt="Image"
@load="isLoaded = true"
style="width: 100%; height: 100%;"
>
</a>
<input ref="inputRef" type="hidden" value="" :id="`input-image-${id}`">
@@ -48,11 +49,6 @@ onMounted(() => {
</script>
<style scoped>
.oc-image {
display: flex;
justify-content: center;
align-items: center;
}
.loader {
width: 100px;
height: 100px;

View File

@@ -0,0 +1,27 @@
<template>
<SettingsItem :label="label">
<template #default>
<Switcher v-model="model"/>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import Switcher from "@/components/Switcher.vue";
import SettingsItem from "@/components/SettingsItem.vue";
const model = defineModel();
const props = defineProps({
label: {
type: String,
default: '',
},
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,116 @@
<template>
<SettingsItem :label="label">
<template #default>
<input
ref="searchInput"
type="text"
placeholder="Начните вводить название категории..."
class="form-control"
autocomplete="off"
/>
<div class="well well-sm tw:h-90 tw:overflow-auto">
<div v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
Загрузка списка категорий...
</div>
<div v-else v-for="(product, index) in selectedProducts"
class="tw:flex tw:items-center tw:mb-1">
<button
@click.prevent="removeItem(index)"
class="btn btn-xs btn-danger"
>
<i class="fa fa-minus-circle"></i>
</button>
<div class="tw:ml-3">{{ product.name }}</div>
</div>
</div>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import {nextTick, onMounted, ref, watch} from "vue";
import {apiPost} from "@/utils/http.js";
const searchInput = ref(null);
const isLoading = ref(false);
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: ''
},
readonly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
});
const model = defineModel();
function removeItem(index) {
model.value.splice(index, 1);
}
const selectedProducts = ref([]);
watch(
model.value,
async (ids) => {
if (!ids?.length) {
selectedProducts.value = [];
return;
}
try {
isLoading.value = true;
const response = await apiPost('getCategoriesById', {
category_ids: ids,
});
selectedProducts.value = response.data.data;
} catch (err) {
console.error(err);
} finally {
isLoading.value = false;
}
},
{immediate: true}
);
onMounted(() => {
nextTick(() => {
if (searchInput.value) {
$(searchInput.value).autocomplete({
source: function (request, response) {
$.ajax({
url: `/admin/index.php?route=catalog/category/autocomplete&user_token=${window.TeleCart.user_token}&filter_name=${encodeURIComponent(request)}`,
dataType: 'json',
success: function (json) {
response($.map(json, function (item) {
return {
label: item['name'],
value: Number(item['category_id']),
};
}));
}
});
},
select: function (item) {
model.value.push(item['value']);
}
});
}
});
});
</script>

View File

@@ -0,0 +1,26 @@
<template>
<SettingsItem :label="label">
<template #default>
<OcImagePicker v-model="model"/>
</template>
<template #help><slot></slot></template>
</SettingsItem>
</template>
<script setup>
import OcImagePicker from "@/components/OcImagePicker.vue";
import SettingsItem from "@/components/SettingsItem.vue";
const model = defineModel();
const props = defineProps({
label: {
type: String,
default: '',
},
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,45 @@
<template>
<SettingsItem :label="label">
<template #default>
<InputText
:type="type"
v-model="model"
class="form-control"
:placeholder="placeholder"
:readonly="readonly"
/>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import InputText from 'primevue/inputtext';
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Введите значение'
},
type: {
type: String,
default: 'text',
},
readonly: {
type: Boolean,
default: false,
},
});
const model = defineModel();
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,116 @@
<template>
<SettingsItem :label="label">
<template #default>
<input
ref="productsInput"
type="text"
placeholder="Начните вводить название товара..."
class="form-control"
autocomplete="off"
/>
<div class="well well-sm tw:h-90 tw:overflow-auto">
<div v-if="isLoading">
<i class="fa fa-spinner fa-spin"></i>
Загрузка списка товаров...
</div>
<div v-else v-for="(product, index) in selectedProducts"
class="tw:flex tw:items-center tw:mb-1">
<button
@click.prevent="removeItem(index)"
class="btn btn-xs btn-danger"
>
<i class="fa fa-minus-circle"></i>
</button>
<div class="tw:ml-3">{{ product.name }}</div>
</div>
</div>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import {nextTick, onMounted, ref, watch} from "vue";
import {apiPost} from "@/utils/http.js";
const productsInput = ref(null);
const isLoading = ref(false);
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: ''
},
readonly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
});
const model = defineModel();
function removeItem(index) {
model.value.splice(index, 1);
}
const selectedProducts = ref([]);
watch(
model.value,
async (ids) => {
if (!ids?.length) {
selectedProducts.value = [];
return;
}
try {
isLoading.value = true;
const response = await apiPost('getProductsById', {
product_ids: ids,
});
selectedProducts.value = response.data.data;
} catch (err) {
console.error(err);
} finally {
isLoading.value = false;
}
},
{immediate: true}
);
onMounted(() => {
nextTick(() => {
if (productsInput.value) {
$(productsInput.value).autocomplete({
source: function (request, response) {
$.ajax({
url: `/admin/index.php?route=catalog/product/autocomplete&user_token=${window.TeleCart.user_token}&filter_name=${encodeURIComponent(request)}`,
dataType: 'json',
success: function (json) {
response($.map(json, function (item) {
return {
label: item['name'],
value: Number(item['product_id']),
};
}));
}
});
},
select: function (item) {
model.value.push(item['value']);
}
});
}
});
});
</script>

View File

@@ -0,0 +1,34 @@
<template>
<SettingsItem :label="label">
<template #default>
<select class="form-control" v-model="model">
<option v-for="(value, key) in items" :value="key" :key="key">
{{ value }}
</option>
</select>
</template>
<template #help>
<slot/>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
const model = defineModel();
const props = defineProps({
items: {
type: Object,
default: {},
},
label: {
type: String,
default: '',
},
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,41 @@
<template>
<SettingsItem :label="label">
<template #default>
<Textarea
v-model="model"
class="form-control"
:placeholder="placeholder"
:readonly="readonly"
:rows="rows"
/>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import SettingsItem from "@/components/SettingsItem.vue";
import Textarea from 'primevue/textarea';
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: ''
},
readonly: {
type: Boolean,
default: false,
},
rows: {
type: Number,
default: 5,
},
});
const model = defineModel();
</script>

View File

@@ -0,0 +1,145 @@
<template>
<SettingsItem :label="label">
<template #default>
<div class="tw:flex tw:w-full">
<span class="tw:flex">
<button
class="btn btn-primary tw:whitespace-nowrap"
type="button"
@click="validateBotToken"
:disabled="isLoading || ! settings.items.telegram.bot_token"
:class="{
'tw:opacity-60 tw:cursor-not-allowed': isLoading
}"
>
<i
:class="isLoading ? 'fa fa-spinner fa-spin tw:mr-1' : 'fa fa-refresh tw:mr-1'"
></i>
{{ isLoading ? 'Проверяю...' : 'Проверить Bot Token' }}
</button>
</span>
<input
type="text"
v-model="model"
@input="handleInput"
@blur="validateBotToken"
placeholder="Введите токен от Telegram бота"
class="form-control"
:readonly="isLoading"
/>
</div>
<div
v-if="validationStatus"
class="alert"
:class="validationStatusClass"
>
{{ validationStatus }}
</div>
</template>
<template #help>
Подробная инструкция доступна в
<a href="https://telecart-labs.github.io/docs/telegram/telegram/#%D1%81%D0%BE%D0%B7%D0%B4%D0%B0%D0%BD%D0%B8%D0%B5-%D0%B1%D0%BE%D1%82%D0%B0" target="_blank">документации
<i class="fa fa-external-link"></i>
</a>.
</template>
</SettingsItem>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {ref, computed} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import {apiPost} from "@/utils/http.js";
const model = defineModel();
const settings = useSettingsStore();
const validationStatus = ref(null);
const isLoading = ref(false);
const props = defineProps({
label: {
type: String,
default: '',
},
});
const validationStatusClass = computed(() => {
if (!validationStatus.value) return '';
if (validationStatus.value.startsWith('✅')) {
return 'alert-success';
}
if (validationStatus.value.startsWith('❌')) {
return 'alert-danger';
}
return 'alert-info';
});
function handleInput(event) {
model.value = event.target.value;
// Сбрасываем статус валидации при изменении токена
if (validationStatus.value) {
validationStatus.value = null;
}
}
async function validateBotToken() {
const botToken = model.value?.trim() || '';
// Валидация пустого токена
if (botToken.length === 0) {
validationStatus.value = '❌ Введите Bot Token!';
return;
}
// Сбрасываем предыдущий статус
validationStatus.value = null;
isLoading.value = true;
try {
const result = await apiPost('configureBotToken', { botToken });
if (!result.success) {
// Обработка ошибок
if (result.status === 422) {
validationStatus.value = `❌ Ошибка: ${result.error || 'Неверный токен'}`;
} else {
validationStatus.value = `❌ Ошибка проверки BotToken: ${result.error || 'Неизвестная ошибка'}`;
}
return;
}
const response = result.data;
// Проверка наличия обязательных полей в ответе
if (!response?.id) {
validationStatus.value = '❌ Ошибка: bot token не найден в ответе сервера.';
console.error('Неожиданный формат ответа:', response);
return;
}
// Успешная валидация
const username = response.username ? `@${response.username}` : 'не указан';
const webhookUrl = response.webhook_url || 'не настроен';
validationStatus.value = `✅ Бот: ${username} (id: ${response.id}) webhook: ${webhookUrl}`;
// Обновляем токен в store, если нужно (на случай если сервер что-то изменил)
if (response.bot_token && response.bot_token !== botToken) {
model.value = response.bot_token;
}
} catch (error) {
console.error('Ошибка при валидации BotToken:', error);
validationStatus.value = '❌ Ошибка проверки BotToken. Проверьте подключение к серверу.';
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,173 @@
<template>
<SettingsItem :label="label">
<template #default>
<template v-if="settings.items.telegram.bot_token">
<div class="tw:flex tw:w-full">
<span class="tw:flex">
<button
class="btn btn-primary tw:whitespace-nowrap"
type="button"
@click="getChatId"
:disabled="isLoading || !settings.items.telegram.bot_token"
:class="{
'tw:opacity-60 tw:cursor-not-allowed': isLoading
}"
>
<i
:class="isLoading ? 'fa fa-spinner fa-spin tw:mr-1' : 'fa fa-refresh tw:mr-1'"
></i>
{{ isLoading ? 'Получаю...' : 'Получить Chat ID' }}
</button>
</span>
<input
type="text"
v-model="model"
@input="handleInput"
:placeholder="placeholder"
class="form-control"
:readonly="isLoading"
/>
</div>
<div
v-if="statusMessage"
class="alert"
:class="statusMessageClass"
>
{{ statusMessage }}
</div>
<button
class="btn btn-link btn-xs"
type="button"
data-toggle="collapse"
:data-target="`#${collapseId}`"
aria-expanded="false"
:aria-controls="collapseId"
>
Инструкция как получить ChatID.
</button>
<div class="collapse" :id="collapseId">
<div class="well">
<p class="text-primary">Как получить Chat ID</p>
<ol>
<li>Убедитесь, что Telegram Bot Token введён выше.</li>
<li>Откройте вашего бота в Telegram и отправьте ему кодовое слово: <code>opencart_get_chatid</code>. Важно отправить именно такое сообщение, иначе не сработает.</li>
<li>Вернитесь сюда и нажмите кнопку «Получить Chat ID» скрипт автоматически подставит его в поле ниже.</li>
</ol>
</div>
</div>
</template>
<div v-else class="alert alert-warning">
<strong>BotToken</strong> не указан. Пожалуйста, введите корректный BotToken. После этого здесь станет доступна настройка ChatID.
</div>
</template>
<template #help>
Идентификатор Telegram-чата, куда будут отправляться уведомления о новых заказах. Если оставить поле пустым, уведомления отправляться не будут.
</template>
</SettingsItem>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {ref, computed, useId} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import {apiGet} from "@/utils/http.js";
const model = defineModel();
const settings = useSettingsStore();
const statusMessage = ref(null);
const isLoading = ref(false);
const collapseId = useId();
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Chat ID будет получен автоматически',
},
});
const statusMessageClass = computed(() => {
if (!statusMessage.value) return '';
if (statusMessage.value.startsWith('✅')) {
return 'alert-success';
}
if (statusMessage.value.startsWith('❌')) {
return 'alert-danger';
}
return 'alert-info';
});
function handleInput(event) {
model.value = event.target.value;
// Сбрасываем статус сообщения при изменении значения
if (statusMessage.value) {
statusMessage.value = null;
}
}
async function getChatId() {
// Проверка наличия bot_token
if (!settings.items.telegram.bot_token?.trim()) {
alert('Сначала введите Telegram Bot Token!');
return;
}
// Сбрасываем предыдущее сообщение
statusMessage.value = null;
isLoading.value = true;
try {
const response = await apiGet('getChatId');
if (!response.success) {
// Обработка ошибок
const errorMessage = response.data?.message || response.error || 'Неизвестная ошибка';
if (response.status === 422) {
statusMessage.value = `${errorMessage}`;
} else {
statusMessage.value = `❌ Ошибка получения Chat ID: ${errorMessage}`;
}
return;
}
// Проверка наличия chat_id в ответе
if (!response.data?.chat_id) {
statusMessage.value = '❌ Ошибка: Chat ID не найден в ответе сервера.';
console.error('Неожиданный формат ответа:', response);
return;
}
// Успешное получение Chat ID
const chatId = response.data.chat_id;
model.value = chatId;
statusMessage.value = '✅ ChatID успешно получен и подставлен в поле. Не забудьте сохранить настройки!';
} catch (error) {
console.error('Ошибка при получении Chat ID:', error);
statusMessage.value = '❌ Ошибка получения Chat ID. Проверьте подключение к серверу.';
} finally {
isLoading.value = false;
}
}
</script>
<style scoped>
code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
</style>

View File

@@ -0,0 +1,169 @@
<template>
<SettingsItem :label="label">
<template #default>
<div style="margin-bottom: 10px;">
<textarea
v-model="model"
:rows="rows"
:placeholder="placeholder"
class="form-control"
></textarea>
</div>
<div>
<button
class="btn btn-link"
type="button"
data-toggle="collapse"
:data-target="`#${collapseId}`"
aria-expanded="false"
:aria-controls="collapseId"
>
Документация
</button>
<button
type="button"
class="btn btn-primary btn-sm"
@click="sendTestMessage"
:disabled="isSending"
:class="{
'tw:opacity-60 tw:cursor-not-allowed': isSending
}"
>
<i :class="isSending ? 'fa fa-spinner fa-spin' : 'fa fa-envelope'"></i>
{{ isSending ? 'Отправляю...' : 'Отправить тестовое уведомление' }}
</button>
</div>
<div class="collapse" :id="collapseId" style="margin-top: 15px">
<div class="well">
<p>Вы можете использовать переменные:</p>
<ul>
<li><code>{store_name}</code> название магазина</li>
<li><code>{order_id}</code> номер заказа</li>
<li><code>{customer}</code> имя и фамилия покупателя</li>
<li><code>{email}</code> email покупателя</li>
<li><code>{phone}</code> телефон</li>
<li><code>{comment}</code> комментарий к заказу</li>
<li><code>{address}</code> адрес доставки</li>
<li><code>{total}</code> сумма заказа</li>
<li><code>{ip}</code> IP покупателя</li>
<li><code>{created_at}</code> дата и время создания заказа</li>
</ul>
<p>
Форматирование: поддерживается
<a href="https://core.telegram.org/bots/api#markdownv2-style" target="_blank">
*MarkdownV2*
<i class="fa fa-external-link"></i>
</a>.
</p>
<p>Символы, которые нужно экранировать в тексте:</p>
<pre>_ * [ ] ( ) ~ ` > # + - = | { } . !</pre>
<p>
Каждый из них нужно экранировать обратным слэшем \, если он не используется для форматирования.
Например вместо <code>Заказ #123</code> нужно писать <code>Заказ \#123</code>.
</p>
</div>
</div>
</template>
<template #help>
<slot></slot>
</template>
</SettingsItem>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {ref, toRaw, useId} from "vue";
import SettingsItem from "@/components/SettingsItem.vue";
import {apiPost} from "@/utils/http.js";
const model = defineModel();
const settings = useSettingsStore();
const isSending = ref(false);
const collapseId = useId();
const props = defineProps({
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Введите шаблон сообщения',
},
rows: {
type: Number,
default: 5,
},
});
async function sendTestMessage() {
console.log(toRaw(settings.items.telegram));
const telegramToken = settings.items.telegram.bot_token?.trim();
if (!telegramToken) {
alert('Сначала введите Telegram Bot Token!');
return;
}
const chatId = settings.items.telegram.chat_id;
if (!chatId) {
alert('Сначала введите Chat ID!');
return;
}
const template = model.value?.trim();
if (!template) {
alert('Сначала задайте шаблон!');
return;
}
isSending.value = true;
try {
const result = await apiPost('testTgMessage', {
token: telegramToken,
chat_id: chatId,
template: template,
});
if (!result.success) {
const errorMessage = result.data?.message || result.error || 'Неизвестная ошибка';
alert(`Ошибка: ${errorMessage}`);
return;
}
const response = result.data;
alert(response.message || 'Уведомление успешно отправлено');
} catch (error) {
console.error('Ошибка при отправке тестового сообщения:', error);
alert('Ошибка при отправке тестового сообщения');
} finally {
isSending.value = false;
}
}
</script>
<style scoped>
code {
background-color: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
}
pre {
background-color: #f5f5f5;
padding: 10px;
border-radius: 3px;
font-family: monospace;
font-size: 0.9em;
overflow-x: auto;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<ItemInput
:label="label"
type="text"
:readonly="true"
:modelValue="model"
>
Токен, полученный при создании бота через @BotFather.
Он используется для взаимодействия модуля с Telegram API.
Подробная инструкция доступна в
<a href="https://nikitakiselev.github.io/telecart-docs/#telegram" target="_blank">
документации <i class="fa fa-external-link"></i>
</a>.
</ItemInput>
</template>
<script setup>
import ItemInput from "@/components/Settings/ItemInput.vue";
const props = defineProps({
label: {
type: String,
default: '',
},
});
const model = defineModel();
</script>
<style scoped lang="scss">
</style>

View File

@@ -157,7 +157,7 @@ import LinkSelector from "@/components/Slider/LinkSelector.vue";
import SettingsItem from "@/components/SettingsItem.vue";
import Switcher from "@/components/Switcher.vue";
const slider = ref({});
const slider = defineModel();
function removeSlide(index) {
slider.value.slides.splice(index, 1);
@@ -173,10 +173,6 @@ function addSlide() {
image: '',
});
}
onMounted(() => {
slider.value = JSON.parse(window.TeleCart.mainpage_slider);
});
</script>
<style scoped>

View File

@@ -1,23 +1,10 @@
<template>
<div class="btn-group btn-toggle tw:mt-3">
<button
class="btn btn-xs"
:class="{active: model === true, 'btn-success': model === true, 'btn-default' : model === false }"
@click.prevent="model = true"
>
Вкл
</button>
<button
class="btn btn-xs"
:class="{active: model === false, 'btn-danger': model === false, 'btn-default' : model === true }"
@click.prevent="model = false"
>
Выкл
</button>
</div>
<ToggleSwitch v-model="model" />
</template>
<script setup>
import ToggleSwitch from 'primevue/toggleswitch';
const model = defineModel({
default: false,
});

View File

@@ -0,0 +1,112 @@
<template>
<div class="tw:bg-surface-0 tw:dark:bg-surface-950 tw:px-6 tw:py-8 tw:md:px-12 tw:lg:px-20">
<div class="tw:flex tw:items-center tw:flex-col tw:lg:flex-row tw:lg:justify-between">
<div class="tw:flex tw:items-start tw:flex-col tw:lg:flex-row tw:gap-8">
<OcImagePicker v-model="settings.items.app.app_icon" class="tw:w-[6.42rem] tw:h-[6.42rem]"/>
<div class="tw:flex tw:flex-col tw:gap-4">
<div class="tw:flex tw:items-center">
<span class="tw:text-surface-900 tw:dark:text-surface-0 tw:font-bold tw:text-3xl">
{{ settings.items.app.app_name }}
</span>
<a
v-if="tgMe?.result?.first_name"
:href="`https://t.me/${tgMe?.result?.username}`"
class="tw:ml-2 tw:text-surface-900 tw:dark:text-surface-0 tw:text-xl">
@{{ tgMe?.result?.first_name }}
</a>
</div>
<div class="tw:flex tw:items-center tw:flex-wrap tw:gap-8">
<div>
<span class="tw:text-surface-500 tw:dark:text-surface-300">Количество заказов</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
{{ stats.items.orders_count ?? '-' }}
</div>
</div>
<div>
<span class="tw:text-surface-500 tw:dark:text-surface-300">Общая сумма</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
{{ stats.items.orders_total_amount ?? '-' }}
</div>
</div>
<div>
<span class="tw:text-surface-500 tw:dark:text-surface-300">Уникальные товары</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
{{ stats.items.order_products_count ?? '-' }}
</div>
</div>
<div>
<span class="tw:text-surface-500 tw:dark:text-surface-300">Статус магазина</span>
<div
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
<div v-if="settings.items.app.app_enabled" class="tw:flex tw:items-center">
<div class="tw:h-2 tw:w-2 tw:rounded-full tw:bg-green-400 tw:flex tw:mr-2">
<span
class="tw:inline-flex tw:h-full tw:w-full tw:animate-ping tw:rounded-full tw:bg-green-400 tw:opacity-75"></span>
</div>
<div>Online</div>
</div>
<div v-else
class="tw:text-surface-700 tw:dark:text-surface-100 tw:mt-1 tw:text-sm tw:font-semibold">
<div class="tw:flex tw:items-center">
<div class="tw:h-2 tw:w-2 tw:rounded-full tw:bg-red-400 tw:flex tw:mr-2">
<span
class="tw:inline-flex tw:h-full tw:w-full tw:animate-ping tw:rounded-full tw:bg-red-400 tw:opacity-75"></span>
</div>
<div>Offline</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tw:mt-6 tw:lg:mt-0 tw:flex tw:items-center tw:gap-4">
<div class="btn-group">
<a
class="btn btn-primary"
:class="{'disabled': (tgMe?.result?.has_main_web_app !== true)}"
rounded
:href="`https://t.me/${tgMe?.result?.username}?startapp`"
target="_blank"
:title="(tgMe?.result?.has_main_web_app !== true) ? 'Вы не привязали Telegram Mini App к боту.' : 'Открыть Telegram магазин'"
>
<i class="fa fa-play"></i>
</a>
<a class="btn btn-default" target="_blank" href="https://telecart-labs.github.io/docs/" title="Документация по модулю TeleCart">
<i class="fa fa-book"></i>
</a>
<a class="btn btn-default" target="_blank" href="https://t.me/ocstore3" title="Официальная Telegram группа модуля TeleCart">
<i class="fa fa-group"></i>
</a>
</div>
</div>
</div>
</div>
</template>
<script setup>
import Button from "primevue/button";
import {useSettingsStore} from "@/stores/settings.js";
import {useStatsStore} from "@/stores/stats.js";
import {onMounted, ref} from "vue";
import OcImagePicker from "@/components/OcImagePicker.vue";
import {apiGet} from "@/utils/http.js";
const settings = useSettingsStore();
const stats = useStatsStore();
const tgMe = ref(null);
onMounted(async () => {
await stats.fetchStats();
const response = await apiGet('tgGetMe');
tgMe.value = response.data;
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -3,6 +3,15 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import {useSettingsStore} from "@/stores/settings.js";
import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import ToastService from 'primevue/toastservice';
import {definePreset} from "@primeuix/themes";
const MyPreset = definePreset(Aura, {
});
function onReady(fn) {
if (document.readyState === 'loading') {
@@ -12,9 +21,20 @@ function onReady(fn) {
}
}
onReady(() => {
onReady(async () => {
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(PrimeVue, {
theme: {
preset: MyPreset,
options: {
cssLayer: false, // если используешь Tailwind, отключает layering
},
}
});
app.use(ToastService);
app.mount('#app');
await useSettingsStore().fetchSettings();
});

View File

@@ -1,15 +1,23 @@
import {createMemoryHistory, createRouter} from 'vue-router'
import HomeView from '../views/HomeView.vue'
import {createMemoryHistory, createRouter} from 'vue-router';
import SliderView from "@/views/SliderView.vue";
import GeneralView from "@/views/GeneralView.vue";
import TextsView from "@/views/TextsView.vue";
import OrdersView from "@/views/OrdersView.vue";
import TelegramView from "@/views/TelegramView.vue";
import MetricsView from "@/views/MetricsView.vue";
import StoreView from "@/views/StoreView.vue";
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{path: '/', name: 'general', component: GeneralView},
{path: '/slider', name: 'slider', component: SliderView},
{path: '/orders', name: 'orders', component: OrdersView},
{path: '/texts', name: 'texts', component: TextsView},
{path: '/telegram', name: 'telegram', component: TelegramView},
{path: '/metrics', name: 'metrics', component: MetricsView},
{path: '/store', name: 'store', component: StoreView},
],
})
});
export default router
export default router;

View File

@@ -0,0 +1,125 @@
import {defineStore} from "pinia";
import {apiGet, apiPost} from "@/utils/http.js";
import {toastBus} from "@/utils/toastHelper.js";
export const useSettingsStore = defineStore('settings', {
state: () => ({
isLoading: false,
error: null,
items: {
app: {
app_enabled: true,
app_name: '',
app_icon: null,
theme_light: 'light',
theme_dark: 'dark',
app_debug: false,
},
telegram: {
mini_app_url: '',
bot_token: '',
chat_id: '',
owner_notification_template: '',
customer_notification_template: '',
},
metrics: {
yandex_metrika_enabled: false,
yandex_metrika_counter: '',
},
store: {
enable_store: true,
mainpage_products: 'most_viewed',
featured_products: [],
mainpage_categories: 'latest10',
featured_categories: [],
feature_coupons: true,
feature_vouchers: true,
},
orders: {
order_default_status_id: 1,
},
texts: {
text_no_more_products: '',
text_empty_cart: '',
text_order_created_success: '',
},
sliders: {
mainpage_slider: {
is_enabled: false,
effect: "slide",
pagination: true,
scrollbar: false,
free_mode: false,
space_between: 30,
autoplay: false,
loop: false,
slides: [],
},
},
},
}),
getters: {
app_icon_preview: (state) => {
if (!state.items.app.app_icon) return '/image/cache/no_image-100x100.png';
const extIndex = state.items.app.app_icon.lastIndexOf('.');
const ext = state.items.app.app_icon.substring(extIndex);
const filename = state.items.app.app_icon.substring(0, extIndex);
return `/image/cache/${filename}-100x100${ext}`;
},
},
actions: {
async fetchSettings() {
this.isLoading = true;
this.error = null;
const response = await apiGet('getSettingsForm');
if (response.success) {
this.items = {
...this.items,
...response.data,
};
} else {
this.error = 'Возникли проблемы при загрузке настроек.';
}
this.isLoading = false;
},
async saveSettings() {
this.isLoading = true;
const settings = this.transformSettingsToStore(this.items);
const response = await apiPost('saveSettingsForm', settings);
if (response.success === true) {
toastBus.emit('show', {
severity: 'success',
summary: 'Готово!',
detail: 'Настройки сохранены.',
life: 2000,
});
} else {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: 'Возникли проблемы при сохранении настроек на сервере.',
life: 2000,
});
}
this.isLoading = false;
},
transformSettingsToStore(items) {
return items;
},
},
});

View File

@@ -0,0 +1,22 @@
import {defineStore} from "pinia";
import {apiGet, apiPost} from "@/utils/http.js";
export const useStatsStore = defineStore('stats', {
state: () => ({
items: {
orders_count: null,
orders_total_amount: null,
order_products_count: null,
}
}),
actions: {
async fetchStats() {
const response = await apiPost('getDashboardStats');
this.items.orders_count = response.data?.data?.orders_count;
this.items.orders_total_amount = response.data?.data?.orders_total_amount;
this.items.order_products_count = response.data?.data?.order_products_count;
}
},
});

View File

@@ -0,0 +1,142 @@
import axios from 'axios';
/**
* Получает user_token из глобального объекта TeleCart
*/
function getUserToken() {
if (typeof window !== 'undefined' && window.TeleCart?.user_token) {
return window.TeleCart.user_token;
}
// Fallback: пытаемся получить из URL как запасной вариант
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('user_token') || '';
}
return '';
}
/**
* Базовый URL для API запросов
*/
function getBaseUrl() {
return '/admin/index.php';
}
/**
* Создает URL для API запроса
* @param {string} apiAction - действие API (например, 'configureBotToken')
* @returns {string} полный URL
*/
function buildApiUrl(apiAction) {
const baseUrl = getBaseUrl();
const userToken = getUserToken();
return `${baseUrl}?route=extension/module/tgshop/handle&api_action=${apiAction}&user_token=${userToken}`;
}
/**
* HTTP клиент для работы с API
*/
const httpClient = axios.create({
headers: {
'Content-Type': 'application/json',
},
});
/**
* Выполняет POST запрос к API
* @param {string} apiAction - действие API
* @param {object} data - данные для отправки
* @returns {Promise} результат запроса
*/
export async function apiPost(apiAction, data = {}) {
const url = buildApiUrl(apiAction);
try {
const response = await httpClient.post(url, data);
return {
success: true,
data: response.data,
status: response.status,
};
} catch (error) {
// Обработка ошибок axios
if (error.response) {
// Сервер вернул ошибку
const status = error.response.status;
const errorData = error.response.data;
return {
success: false,
error: errorData?.error || error.response.statusText,
status,
data: errorData,
};
} else if (error.request) {
// Запрос был отправлен, но ответа не получено
return {
success: false,
error: 'Не удалось получить ответ от сервера',
status: 0,
};
} else {
// Ошибка при настройке запроса
return {
success: false,
error: error.message || 'Произошла неизвестная ошибка',
status: 0,
};
}
}
}
/**
* Выполняет GET запрос к API
* @param {string} apiAction - действие API
* @param {object} params - query параметры
* @returns {Promise} результат запроса
*/
export async function apiGet(apiAction, params = {}) {
const url = buildApiUrl(apiAction);
try {
const response = await httpClient.get(url, { params: params });
return {
success: true,
data: response.data.data,
status: response.status,
};
} catch (error) {
if (error.response) {
const status = error.response.status;
const errorData = error.response.data;
return {
success: false,
error: errorData?.error || error.response.statusText,
status,
data: errorData,
};
} else if (error.request) {
return {
success: false,
error: 'Не удалось получить ответ от сервера',
status: 0,
};
} else {
return {
success: false,
error: error.message || 'Произошла неизвестная ошибка',
status: 0,
};
}
}
}
export default {
apiPost,
apiGet,
getUserToken,
};

View File

@@ -0,0 +1,2 @@
import mitt from 'mitt';
export const toastBus = mitt();

View File

@@ -0,0 +1,56 @@
<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>
<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>
</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.TeleCart.themes);
</script>
<style scoped>
</style>

View File

@@ -1,7 +0,0 @@
<template>
<Slider/>
</template>
<script setup>
import Slider from "@/components/Slider/Slider.vue";
</script>

View File

@@ -0,0 +1,29 @@
<template>
<ItemBool
label="Яндекс.Метрика"
v-model="settings.items.metrics.yandex_metrika_enabled"
>
Задействовать Яндекс.Метрику для Telegram магазина.
</ItemBool>
<ItemTextarea
label="Код счётчика Яндекс Метрики"
v-model="settings.items.metrics.yandex_metrika_counter"
placeholder="Вставьте код счётчика Яндекс.Метрики"
>
<p>Код счётчика нужно предварительно настроить, чтобы он работал корректно с Telegram Mini App.
<a href="https://telecart-labs.github.io/docs/analitycs/start/" target="_blank">
Инструкция как настроить i.fa.fa-external-link
</a>.</p>
<p>Для проверки интеграции через кнопку "Проверить" в интерфейсе Яндекс Метрики,
необходимо сначала включить "Режим разработчика" на вкладке "Общие".</p>
</ItemTextarea>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import ItemTextarea from "@/components/Settings/ItemTextarea.vue";
import ItemBool from "@/components/Settings/ItemBool.vue";
const settings = useSettingsStore();
</script>

View 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.TeleCart.order_statuses);
</script>

View File

@@ -0,0 +1,10 @@
<template>
<Slider v-model="settings.items.sliders.mainpage_slider"/>
</template>
<script setup>
import Slider from "@/components/Slider/Slider.vue";
import {useSettingsStore} from "@/stores/settings.js";
const settings = useSettingsStore();
</script>

View File

@@ -0,0 +1,80 @@
<template>
<ItemBool label="Разрешить покупки" v-model="settings.items.store.enable_store">
<p>Если опция <strong>включена</strong> пользователи смогут оформлять
заказы прямо в Telegram-магазине. <br>
Если <strong>выключена</strong> оформление заказов будет недоступно. Вместо кнопки «Добавить
в корзину» пользователи увидят кнопку «Перейти к товару», которая откроет страницу товара на
вашем сайте. В этом режиме Telecart работает как каталог.</p>
</ItemBool>
<ItemSelect
label="Товары на главной"
v-model="settings.items.store.mainpage_products"
:items="mainpage_products_options"
>
Выберите, какие товары показывать на главной странице магазина в Telegram.
Это влияет на первую видимую секцию каталога для пользователя.
</ItemSelect>
<ItemProductsSelect
label="Избранные товары"
v-model="settings.items.store.featured_products"
>
На главной странице будут отображаться избранные товары, если вы выберете этот вариант в
настройке Товары на главной. Если товары не выбраны, то будут показаны популярные товары.
</ItemProductsSelect>
<ItemSelect
label="Категории на главной"
v-model="settings.items.store.mainpage_categories"
:items="mainpage_categories_options"
>
Выберите, какие товары показывать на главной странице магазина в Telegram.
Это влияет на первую видимую секцию каталога для пользователя.
</ItemSelect>
<ItemCategoriesSelect
label="Избранные категории"
v-model="settings.items.store.featured_categories"
>
На главной странице будут отображаться эти категории,
если вы выберете этот вариант в настройке Категории на главной.
</ItemCategoriesSelect>
<ItemBool label="Промокоды" v-model="settings.items.store.feature_coupons">
<p>
Позволяет использовать стандартные
<a :href="`/admin/index.php?route=marketing/coupon&user_token=${userToken}`"
target="_blank">купоны OpenCart</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">подарочные сертификаты OpenCart</a> при оформлении заказа.</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 ItemProductsSelect from "@/components/Settings/ItemProductsSelect.vue";
import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue";
const settings = useSettingsStore();
const mainpage_products_options = {
most_viewed: 'Популярные товары',
latest: 'Последние сверху',
featured: 'Избранные товары (задать в поле ниже)',
};
const mainpage_categories_options = {
no_categories: 'Отображать только кнопку "Каталог"',
latest10: 'Последние 10 категорий',
featured: 'Избранные категории (задать в поле ниже)',
};
const userToken = window.TeleCart.user_token;
</script>

View 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>

View File

@@ -0,0 +1,21 @@
<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>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import ItemInput from "@/components/Settings/ItemInput.vue";
const settings = useSettingsStore();
</script>