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:
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.AcmeShop.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>
|
||||
25
frontend/admin/src/components/Settings/ItemImage.vue
Normal file
25
frontend/admin/src/components/Settings/ItemImage.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<OcImagePicker v-model="model" class="tw:w-30"/>
|
||||
</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>
|
||||
72
frontend/admin/src/components/Settings/ItemInput.vue
Normal file
72
frontend/admin/src/components/Settings/ItemInput.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<InputGroup v-if="allowCopy && isSupported">
|
||||
<Button
|
||||
:key="copied ? 'copied' : 'copy'"
|
||||
:icon="copied ? 'fa fa-check' : 'fa fa-copy'"
|
||||
severity="secondary"
|
||||
v-tooltip.top="{ value: copied ? 'Скопировано' : 'Скопировать' }"
|
||||
@click="copyToClipboard"
|
||||
/>
|
||||
<InputText
|
||||
:type="type"
|
||||
v-model="model"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputText
|
||||
v-else
|
||||
: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';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import Button from 'primevue/button';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Введите значение'
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowCopy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const model = defineModel();
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard({ source: model })
|
||||
|
||||
function copyToClipboard() {
|
||||
copy();
|
||||
}
|
||||
</script>
|
||||
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.AcmeShop.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>
|
||||
48
frontend/admin/src/components/Settings/ItemSelect.vue
Normal file
48
frontend/admin/src/components/Settings/ItemSelect.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<select class="form-control" v-model="model">
|
||||
<option
|
||||
v-for="(value, key) in items"
|
||||
:value="normalizeOptionValue(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: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Преобразуем числовые ключи обратно в Number, чтобы v-model не получал строки
|
||||
const normalizeOptionValue = (key) => {
|
||||
if (typeof key === 'number') {
|
||||
return key;
|
||||
}
|
||||
|
||||
const parsed = Number(key);
|
||||
return Number.isNaN(parsed) ? key : parsed;
|
||||
};
|
||||
</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://acme-inc.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>
|
||||
190
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal file
190
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<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>ecommerce_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 parseChatId = (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const normalized = String(value).trim();
|
||||
if (!/^-?\d+$/.test(normalized)) return null;
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Chat ID будет получен автоматически',
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof model.value === 'string') {
|
||||
model.value = parseChatId(model.value);
|
||||
}
|
||||
|
||||
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 = parseChatId(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;
|
||||
}
|
||||
|
||||
const parsedChatId = parseChatId(response.data.chat_id);
|
||||
|
||||
if (parsedChatId === null) {
|
||||
statusMessage.value = '❌ Ошибка: Chat ID вернулся в некорректном формате.';
|
||||
console.error('Некорректный Chat ID в ответе:', response);
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = parsedChatId;
|
||||
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>
|
||||
|
||||
165
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal file
165
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<Codemirror
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
:extensions="extensions"
|
||||
/>
|
||||
</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>
|
||||
Для формирования сообщения используется HTML разметка.
|
||||
Telegram поддерживает только часть HTML тегов, которые описаны в их
|
||||
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">документации <i class="fa fa-external-link"></i></a>.
|
||||
</p>
|
||||
<p>Дополнительно к этому AcmeShop добавляет переменные, которые вы можете использовать, чтобы сделать сообщения динамическими.</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></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";
|
||||
import {Codemirror} from "vue-codemirror";
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
|
||||
const model = defineModel();
|
||||
const settings = useSettingsStore();
|
||||
const isSending = ref(false);
|
||||
const collapseId = useId();
|
||||
const extensions = [html(), oneDark];
|
||||
|
||||
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"
|
||||
:allowCopy="true"
|
||||
>
|
||||
Ссылка на сайт с AcmeShop витриной, которую нужно указывать в настройках MiniApp в @BotFather.<br>
|
||||
Подробная инструкция по настройке в
|
||||
<a href="https://docs.acmeshop.pro/telegram/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>
|
||||
51
frontend/admin/src/components/Settings/ItemToggleButton.vue
Normal file
51
frontend/admin/src/components/Settings/ItemToggleButton.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<SelectButton
|
||||
:modelValue="model"
|
||||
:options="options"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
@update:modelValue="updateValue"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot/>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import SelectButton from "primevue/selectbutton";
|
||||
|
||||
const model = defineModel();
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
return Object.entries(props.items).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
});
|
||||
|
||||
function updateValue(newValue) {
|
||||
model.value = newValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user