feat: update admin page
This commit is contained in:
@@ -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;
|
||||
|
||||
27
frontend/admin/src/components/Settings/ItemBool.vue
Normal file
27
frontend/admin/src/components/Settings/ItemBool.vue
Normal 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>
|
||||
116
frontend/admin/src/components/Settings/ItemCategoriesSelect.vue
Normal file
116
frontend/admin/src/components/Settings/ItemCategoriesSelect.vue
Normal 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>
|
||||
26
frontend/admin/src/components/Settings/ItemImage.vue
Normal file
26
frontend/admin/src/components/Settings/ItemImage.vue
Normal 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>
|
||||
45
frontend/admin/src/components/Settings/ItemInput.vue
Normal file
45
frontend/admin/src/components/Settings/ItemInput.vue
Normal 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>
|
||||
116
frontend/admin/src/components/Settings/ItemProductsSelect.vue
Normal file
116
frontend/admin/src/components/Settings/ItemProductsSelect.vue
Normal 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>
|
||||
34
frontend/admin/src/components/Settings/ItemSelect.vue
Normal file
34
frontend/admin/src/components/Settings/ItemSelect.vue
Normal 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>
|
||||
41
frontend/admin/src/components/Settings/ItemTextarea.vue
Normal file
41
frontend/admin/src/components/Settings/ItemTextarea.vue
Normal 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>
|
||||
145
frontend/admin/src/components/Settings/ItemTgBotToken.vue
Normal file
145
frontend/admin/src/components/Settings/ItemTgBotToken.vue
Normal 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>
|
||||
173
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal file
173
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal 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>
|
||||
|
||||
169
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal file
169
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal 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>
|
||||
|
||||
31
frontend/admin/src/components/Settings/ItemTgMiniAppLink.vue
Normal file
31
frontend/admin/src/components/Settings/ItemTgMiniAppLink.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
112
frontend/admin/src/components/TopLead.vue
Normal file
112
frontend/admin/src/components/TopLead.vue
Normal 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>
|
||||
Reference in New Issue
Block a user