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

This commit is contained in:
2026-03-11 22:08:41 +03:00
commit f329bfa9d9
585 changed files with 65605 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
frontend/admin/.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

36
frontend/admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

View File

@@ -0,0 +1,28 @@
import { defineConfig, globalIgnores } from 'eslint/config'
import globals from 'globals'
import js from '@eslint/js'
import pluginVue from 'eslint-plugin-vue'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from '@vue/eslint-config-prettier/skip-formatting'
export default defineConfig([
{
name: 'app/files-to-lint',
files: ['**/*.{js,mjs,jsx,vue}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
{
languageOptions: {
globals: {
...globals.browser,
},
},
},
js.configs.recommended,
...pluginVue.configs['flat/essential'],
...pluginOxlint.configs['flat/recommended'],
skipFormatting,
])

13
frontend/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

6085
frontend/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
{
"name": "admin",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint:oxlint": "oxlint . --fix -D correctness --ignore-path .gitignore",
"lint:eslint": "eslint . --fix --cache",
"lint": "run-s lint:*",
"format": "prettier --write src/"
},
"dependencies": {
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@formkit/drag-and-drop": "^0.5.3",
"@formkit/i18n": "^1.6.9",
"@formkit/vue": "^1.6.9",
"@primeuix/themes": "^1.2.5",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^14.0.0",
"axios": "^1.13.5",
"codemirror": "^6.0.2",
"daisyui": "^5.4.2",
"js-md5": "^0.8.3",
"mitt": "^3.0.1",
"pinia": "^3.0.3",
"primevue": "^4.4.1",
"tailwindcss": "^4.1.16",
"vue": "^3.5.22",
"vue-codemirror": "^6.1.1",
"vue-router": "^4.6.3",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@eslint/js": "^9.37.0",
"@formkit/icons": "^1.6.9",
"@prettier/plugin-oxc": "^0.0.4",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/eslint-config-prettier": "^10.2.0",
"eslint": "^9.37.0",
"eslint-plugin-oxlint": "~1.23.0",
"eslint-plugin-vue": "~10.5.0",
"globals": "^16.4.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.23.0",
"prettier": "3.6.2",
"vite": "^7.1.11",
"vite-plugin-vue-devtools": "^8.0.3"
}
}

187
frontend/admin/src/App.vue Normal file
View File

@@ -0,0 +1,187 @@
<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'}">
<i class="fa fa-cog"></i> Общие
</RouterLink>
</li>
<li :class="{active: route.name === 'telegram'}">
<RouterLink :to="{name: 'telegram'}">
<i class="fa fa-paper-plane"></i> Telegram
</RouterLink>
</li>
<li :class="{active: route.name === 'metrics'}">
<RouterLink :to="{name: 'metrics'}">
<i class="fa fa-line-chart"></i> Метрика
</RouterLink>
</li>
<li :class="{active: route.name === 'store'}">
<RouterLink :to="{name: 'store'}">
<i class="fa fa-shopping-bag"></i> Витрина
</RouterLink>
</li>
<li :class="{active: route.name === 'texts'}">
<RouterLink :to="{name: 'texts'}">
<i class="fa fa-file-text"></i> Тексты
</RouterLink>
</li>
<li :class="{active: route.name === 'orders'}">
<RouterLink :to="{name: 'orders'}">
<i class="fa fa-shopping-cart"></i> Заказы
</RouterLink>
</li>
<li :class="{active: route.name === 'mainpage'}">
<RouterLink :to="{name: 'mainpage'}">
<i class="fa fa-home"></i> Главная страница
</RouterLink>
</li>
<li :class="{active: route.name === 'formbuilder'}">
<RouterLink :to="{name: 'formbuilder'}">
<i class="fa fa-wpforms"></i> Форма заказа
</RouterLink>
</li>
<li :class="{active: route.name === 'customers'}">
<RouterLink :to="{name: 'customers'}">
<i class="fa fa-users"></i> Покупатели
</RouterLink>
</li>
<li :class="{active: route.name === 'pulse'}">
<RouterLink :to="{name: 'pulse'}">
<i class="fa fa-heartbeat pulse-icon tw:text-red-200"></i> AcmeShop Pulse <span class="pulse-beta-label tw:ml-1 tw:px-1.5 tw:py-0.5 tw:text-xs tw:font-semibold tw:text-white tw:rounded">BETA</span>
</RouterLink>
</li>
<li :class="{active: route.name === 'cron'}">
<RouterLink :to="{name: 'cron'}">
<i class="fa fa-clock-o"></i> CRON
</RouterLink>
</li>
</ul>
<section class="form-horizontal tab-content">
<RouterView/>
</section>
<section>
<Divider/>
<div class="tw:flex tw:items-center tw:justify-start tw:gap-4">
<Button
label="Сохранить настройки"
:disabled="!settings.hasUnsavedChanges"
v-tooltip.top="settings.hasUnsavedChanges ? 'Сохранить изменения' : 'Нет изменений для сохранения'"
@click="settings.saveSettings"
/>
<div v-if="settings.hasUnsavedChanges"
class="tw:flex tw:items-center tw:gap-2 tw:text-red-600">
<i class="fa fa-exclamation-triangle"></i>
<span class="tw:text-sm">Есть несохранённые изменения</span>
</div>
</div>
</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"/>
<ConfirmDialog/>
<ConfirmPopup group="popup"/>
</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, 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";
import Divider from 'primevue/divider';
import ConfirmDialog from 'primevue/confirmdialog';
import ConfirmPopup from 'primevue/confirmpopup';
import {onBeforeUnmount, onMounted} from "vue";
const route = useRoute();
const settings = useSettingsStore();
const toast = useToast();
toastBus.on('show', (data) => toast.add(data));
// Защита от обновления страницы или закрытия вкладки
function handleBeforeUnload(event) {
if (settings.hasUnsavedChanges) {
event.preventDefault();
event.returnValue = 'У вас есть несохранённые изменения. Вы уверены, что хотите покинуть страницу?';
return event.returnValue;
}
}
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
});
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
});
</script>
<style scoped>
@keyframes heartbeat {
0%, 100% {
transform: scale(1);
}
10%, 30% {
transform: scale(1.1);
}
20%, 40% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
.pulse-icon {
animation: heartbeat 1.5s ease-in-out infinite;
display: inline-block;
}
.nav-tabs li.active .pulse-icon {
color: #ef4444; /* red-500 */
}
.pulse-beta-label {
background-color: #fdba74; /* orange-300 - тусклый */
transition: background-color 0.2s ease;
}
.nav-tabs li.active .pulse-beta-label {
background-color: #f97316; /* orange-500 - яркий */
}
</style>

View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,76 @@
@layer theme, base, components, utilities;
@import "tailwindcss/theme.css" layer(theme) prefix(tw);
@import "tailwindcss/utilities.css" layer(utilities) prefix(tw);
@layer components {
.tw\:d-toggle {
width: calc((var(--d-size) * 2) - (var(--border) + var(--d-toggle-p)) * 2) !important;
height: var(--d-size) !important;
border: var(--border) solid currentColor !important;
color: var(--d-input-color) !important;
border-radius: calc(var(--radius-selector) + min(var(--d-toggle-p), var(--radius-selector-max)) + min(var(--border), var(--radius-selector-max))) !important;
padding: var(--d-toggle-p) !important;
}
.tw\:d-toggle:after {
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;
}
legend.p-fieldset-legend {
font-size: 14px;
line-height: inherit;
width: auto;
margin-bottom: 0;
}
.acmeshop-admin-app {
color: var(--color-slate-700);
}
.blueprint-bg {
background-color: #efefef;
opacity: 0.7;
background-image: radial-gradient(#989898 0.65px, #efefef 0.65px);
background-size: 13px 13px;
}
ul.formkit-options {
padding: 0;
margin-bottom: 0;
list-style: none;
}
ul.formkit-options label {
display: inline-flex;
}
ul.formkit-options input[type="radio"] {
position: absolute;
}
input.p-checkbox-input {
position: absolute;
}
label {
margin-bottom: 0;
}

View File

@@ -0,0 +1,48 @@
<template>
<Dropdown
:model-value="modelValue"
:options="options"
option-label="label"
option-value="value"
placeholder="Интервал"
class="tw:w-[14rem] tw:shrink-0"
@update:model-value="$emit('update:modelValue', $event ?? '')"
/>
</template>
<script setup>
import { computed } from 'vue';
import Dropdown from 'primevue/dropdown';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
});
defineEmits(['update:modelValue']);
/** Пресеты интервалов (label — для отображения, value — cron expression) */
const PRESETS = [
{ label: 'Раз в минуту', value: '* * * * *' },
{ label: 'Раз в 5 минут', value: '*/5 * * * *' },
{ label: 'Раз в 10 минут', value: '*/10 * * * *' },
{ label: 'Раз в час', value: '0 * * * *' },
{ label: 'Раз в 3 часа', value: '0 */3 * * *' },
{ label: 'Раз в 6 часов', value: '0 */6 * * *' },
{ label: 'Раз в сутки', value: '0 0 * * *' },
{ label: 'Раз в неделю', value: '0 0 * * 0' },
];
const presetValues = new Set(PRESETS.map((p) => p.value));
/** Только пресеты; если текущее значение не из списка — показываем его в списке (уже сохранённое в БД), чтобы не терять отображение */
const options = computed(() => {
const current = props.modelValue ?? '';
if (!current || presetValues.has(current)) {
return PRESETS;
}
return [{ label: current, value: current }, ...PRESETS];
});
</script>

View File

@@ -0,0 +1,104 @@
<template>
<SettingsItem label="URL для cron-job.org">
<template #default>
<InputGroup>
<Button
icon="fa fa-refresh"
severity="secondary"
:loading="regeneratingUrl"
v-tooltip.top="'Перегенерировать URL'"
@click="confirmRegenerateUrl"
/>
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard"/>
<InputText readonly :model-value="cronJobOrgUrl" class="tw:w-full"/>
</InputGroup>
</template>
<template #help>
Создайте задачу на <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>, укажите этот URL и интервал (например, каждые 5 минут). Метод: GET. Учитывайте лимиты по времени запроса на вашем хостинге для тяжёлых задач возможны таймауты. При утечке URL нажмите «Перегенерировать URL» и обновите задачу на cron-job.org.
</template>
</SettingsItem>
</template>
<script setup>
import { computed, ref } from 'vue';
import { useSettingsStore } from '@/stores/settings.js';
import SettingsItem from '@/components/SettingsItem.vue';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import InputGroup from 'primevue/inputgroup';
import { useConfirm } from 'primevue/useconfirm';
import { toastBus } from '@/utils/toastHelper.js';
import { apiPost } from '@/utils/http.js';
const settings = useSettingsStore();
const confirm = useConfirm();
const regeneratingUrl = ref(false);
const cronJobOrgUrl = computed(() => settings.items.cron?.schedule_url ?? '');
function confirmRegenerateUrl(event) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'После смены URL его нужно будет обновить в задаче на cron-job.org. Продолжить?',
icon: 'pi pi-exclamation-triangle',
rejectProps: { label: 'Отмена', severity: 'secondary', outlined: true },
acceptProps: { label: 'Перегенерировать', severity: 'secondary' },
accept: () => regenerateUrl(),
});
}
async function regenerateUrl() {
regeneratingUrl.value = true;
try {
const res = await apiPost('regenerateCronScheduleUrl', {});
if (res?.success && res.data?.api_key) {
settings.items.cron.api_key = res.data.api_key;
if (res.data.schedule_url !== undefined) {
settings.items.cron.schedule_url = res.data.schedule_url;
}
toastBus.emit('show', {
severity: 'success',
summary: 'URL обновлён',
detail: 'Обновите URL в задаче на cron-job.org',
life: 4000,
});
} else {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: res?.data?.error ?? 'Не удалось перегенерировать URL',
life: 4000,
});
}
} catch (err) {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: err?.response?.data?.error ?? 'Не удалось перегенерировать URL',
life: 4000,
});
} finally {
regeneratingUrl.value = false;
}
}
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(cronJobOrgUrl.value);
toastBus.emit('show', {
severity: 'success',
summary: 'Скопировано',
detail: 'URL скопирован в буфер обмена',
life: 2000,
});
} catch (err) {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать текст',
life: 2000,
});
}
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<span>
<slot name="default" :value="selectedValue">
{{ selectedValue?.label || 'Не выбрана' }}
</slot>
</span>
</template>
<script setup>
import {useAutocompleteStore} from "@/stores/autocomplete.js";
import {computed, onMounted, ref} from "vue";
const autocomplete = useAutocompleteStore();
const isLoading = ref(false);
const props = defineProps({
id: {
type: Number,
default: null,
},
});
function findNodeByKey(nodes, keyToFind) {
for (const node of nodes) {
if (node.key === keyToFind) {
return node;
}
if (node.children?.length) {
const found = findNodeByKey(node.children, keyToFind);
if (found) return found;
}
}
return null;
}
const selectedValue = computed(() => {
const id = props.id;
if (!id) return null;
const node = findNodeByKey(autocomplete.categories || [], id);
if (!node) return null;
return node;
});
onMounted(async () => {
isLoading.value = true;
await autocomplete.fetchCategories();
isLoading.value = false;
});
</script>

View File

@@ -0,0 +1,50 @@
<template>
<TreeSelect
:modelValue="selectedValue"
@update:modelValue="setNewValue"
filter
filterMode="lenient"
:options="autocomplete.categories"
:disabled="disabled"
:loading="isLoading"
:placeholder="placeholder"
class="md:w-80 w-full"
/>
</template>
<script setup>
import TreeSelect from "primevue/treeselect";
import {useAutocompleteStore} from "@/stores/autocomplete.js";
import {computed, onMounted, ref} from "vue";
const autocomplete = useAutocompleteStore();
const isLoading = ref(false);
const model = defineModel();
const props = defineProps({
placeholder: {
type: String,
default: null,
},
disabled: {
type: Boolean,
default: false,
},
});
const selectedValue = computed(() => {
const id = model.value;
return id ? {[String(id)]: true} : null;
});
function setNewValue(event) {
model.value = Number(Object.keys(event)[0]);
}
onMounted(async () => {
isLoading.value = true;
await autocomplete.fetchCategories();
isLoading.value = false;
});
</script>

View File

@@ -0,0 +1,40 @@
<template>
<Button
icon="fa fa-refresh"
severity="warn"
v-tooltip.top="'Сбросить кеш модуля'"
:loading="isLoading"
@click="resetCache"
/>
</template>
<script setup>
import {Button, useToast} from "primevue";
import {ref} from "vue";
import {apiPost} from "@/utils/http.js";
const isLoading = ref(false);
const toast = useToast();
async function resetCache() {
isLoading.value = true;
const response = await apiPost('resetCache');
if (response.success) {
toast.add({
severity: 'success',
summary: 'Выполнено',
detail: 'Кеш модуля сброшен.',
life: 3000
});
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Ошибка при сбросе кеша.',
life: 3000
});
}
isLoading.value = false;
}
</script>

View File

@@ -0,0 +1,70 @@
<template>
<div class="tw:flex-1 tw:flex tw:flex-col tw:gap-2 tw:h-full">
<Message v-if="isCustom" severity="info" class="tw:mb-2">
Вы находитесь в режиме ручного редактирования схемы.
<a
href="https://formkit.com/essentials/schema"
target="_blank"
title="Документация FormKit Schema"
class="tw:ml-1 tw:text-blue-600 hover:tw:underline"
>
Документация по схеме <i class="fa fa-external-link"></i>
</a>
</Message>
<Panel class="tw:flex-1 tw:flex tw:flex-col tw:overflow-hidden">
<template #header>
<div class="tw:flex tw:justify-between tw:items-center tw:w-full">
<div class="tw:flex tw:items-center tw:gap-2">
<span class="tw:font-medium">Редактор FormKit Schema</span>
</div>
<div v-if="error" class="tw:text-red-500 tw:text-sm">
<i class="fa fa-exclamation-circle"></i> {{ error }}
</div>
</div>
</template>
<div class="tw:flex-1 tw:h-full tw:overflow-hidden">
<Codemirror
:modelValue="modelValue"
@update:modelValue="onCodeChange"
placeholder="Code goes here..."
:style="{ height: '600px' }"
:autofocus="true"
:indent-with-tab="true"
:tab-size="2"
:extensions="extensions"
/>
</div>
</Panel>
</div>
</template>
<script setup>
import { Message, Panel } from 'primevue';
import { Codemirror } from 'vue-codemirror';
import { json } from '@codemirror/lang-json';
import { oneDark } from '@codemirror/theme-one-dark';
const props = defineProps({
modelValue: {
type: String,
default: ''
},
error: {
type: String,
default: null
},
isCustom: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:modelValue', 'change']);
const extensions = [json(), oneDark];
function onCodeChange(newVal) {
emit('update:modelValue', newVal);
emit('change', newVal);
}
</script>

View File

@@ -0,0 +1,376 @@
<template>
<div>
<div v-if="!selectedField" class="tw:text-gray-400 tw:text-center tw:py-8">
<i class="fa fa-mouse-pointer tw:text-2xl tw:mb-2"></i>
<p>Выберите поле для настройки</p>
</div>
<div v-else class="tw:space-y-4">
<!-- Тип поля (только для чтения) -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Тип поля</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Тип элемента формы (например, текст, число, выбор). Нельзя изменить после создания.'"
></i>
</div>
<InputText
:value="selectedField.$formkit"
disabled
class="tw:w-full tw:bg-gray-100"
/>
</div>
<!-- Название поля -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Название (name)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Уникальный ключ поля в JSON-объекте данных. Используется при отправке формы. Должен быть на английском.'"
></i>
</div>
<InputText
:modelValue="selectedField.name"
@update:modelValue="onNameChange"
class="tw:w-full"
:class="{ 'p-invalid': nameError }"
placeholder="field_name"
:disabled="selectedField.locked"
/>
<small v-if="nameError" class="p-error tw:text-red-500 tw:text-xs tw:mt-1 tw:block">{{ nameError }}</small>
</div>
<!-- Метка -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Метка (label)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Подпись, которая отображается над полем ввода для пользователя.'"
></i>
</div>
<InputText
:modelValue="selectedField.label"
@update:modelValue="updateField(selectedField.id, { label: $event })"
class="tw:w-full"
placeholder="Название поля"
/>
</div>
<!-- Help Text -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Подсказка (help)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Дополнительный поясняющий текст, который отображается мелким шрифтом под полем.'"
></i>
</div>
<InputText
:modelValue="selectedField.help"
@update:modelValue="updateField(selectedField.id, { help: $event })"
class="tw:w-full"
placeholder="Текст подсказки"
/>
</div>
<!-- Иконки -->
<div class="tw:grid tw:grid-cols-2 tw:gap-2">
<div>
<div class="tw:flex tw:items-baseline tw:gap-2 tw:mb-1">
<label class="tw:block tw:text-sm tw:font-medium">Иконка слева</label>
</div>
<IconPicker
:modelValue="selectedField.prefixIcon"
@update:modelValue="updateField(selectedField.id, { prefixIcon: $event })"
/>
</div>
<div>
<div class="tw:flex tw:items-baseline tw:gap-2 tw:mb-1">
<label class="tw:block tw:text-sm tw:font-medium">Иконка справа</label>
</div>
<IconPicker
:modelValue="selectedField.suffixIcon"
@update:modelValue="updateField(selectedField.id, { suffixIcon: $event })"
/>
</div>
</div>
<!-- Placeholder (для текстовых полей) -->
<div v-if="hasPlaceholder">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Текст-заполнитель (placeholder)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Текст-подсказка внутри поля, который исчезает при начале ввода.'"
></i>
</div>
<InputText
:modelValue="selectedField.placeholder"
@update:modelValue="updateField(selectedField.id, { placeholder: $event })"
class="tw:w-full"
placeholder="Например: Введите ваше имя"
/>
</div>
<!-- Настройки для Range/Number -->
<div v-if="isRangeOrNumber">
<div class="tw:grid tw:grid-cols-2 tw:gap-2">
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Минимум</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Минимально допустимое значение.'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.min)"
@update:modelValue="updateField(selectedField.id, { min: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Максимум</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Максимально допустимое значение.'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.max)"
@update:modelValue="updateField(selectedField.id, { max: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
<div class="tw:col-span-2">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Шаг (step)</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Шаг изменения значения (например, 1 или 0.5).'"
></i>
</div>
<InputNumber
:modelValue="Number(selectedField.step)"
@update:modelValue="updateField(selectedField.id, { step: $event })"
class="tw:w-full"
inputClass="tw:w-full"
/>
</div>
</div>
</div>
<!-- Настройки для Color -->
<div v-if="selectedField.$formkit === 'color'">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Значение по умолчанию</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Цвет, выбранный по умолчанию.'"
></i>
</div>
<div class="tw:flex tw:gap-2 tw:items-baseline">
<div class="tw:relative tw:w-10 tw:h-10 tw:rounded tw:overflow-hidden tw:border tw:border-gray-300">
<input
type="color"
:value="selectedField.value || '#000000'"
@input="updateField(selectedField.id, { value: $event.target.value })"
class="tw:absolute tw:-top-2 tw:-left-2 tw:w-16 tw:h-16 tw:cursor-pointer tw:p-0 tw:border-0"
/>
</div>
<InputText
:modelValue="selectedField.value"
@update:modelValue="updateField(selectedField.id, { value: $event })"
class="tw:flex-1"
placeholder="#000000"
/>
</div>
</div>
<!-- Опции (для select и radio) -->
<div v-if="hasOptions">
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Опции</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs tw:mt-0.5"
v-tooltip.top="'Список вариантов для выбора. Текст - то, что видит пользователь. Значение - то, что отправляется на сервер.'"
></i>
</div>
<div class="tw:space-y-2">
<div
v-for="(option, index) in selectedField.options"
:key="index"
class="tw:flex tw:gap-2 tw:items-center"
>
<InputText
:modelValue="option.label"
@update:modelValue="updateFieldOption(selectedField.id, index, 'label', $event)"
placeholder="Текст"
class="tw:flex-1 tw:w-full"
/>
<InputText
:modelValue="option.value"
@update:modelValue="updateFieldOption(selectedField.id, index, 'value', $event)"
placeholder="Значение"
class="tw:flex-1 tw:w-full"
/>
<Button
icon="fa fa-trash"
severity="danger"
size="small"
text
rounded
@click="removeFieldOption(selectedField.id, index)"
/>
</div>
<Button
label="Добавить опцию"
icon="fa fa-plus"
size="small"
class="tw:w-full"
@click="addFieldOption(selectedField.id)"
/>
</div>
</div>
<!-- Валидация -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Валидация</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Правила проверки данных (FormKit Validation). Разделяются вертикальной чертой |. Например: required|email|length:5,10'"
></i>
</div>
<InputText
:modelValue="selectedField.validation"
@update:modelValue="updateField(selectedField.id, { validation: $event })"
class="tw:w-full"
placeholder="required|email|length:5,10"
/>
<p class="tw:text-xs tw:text-gray-500 tw:mt-1">
Примеры: required, email, length:5,10, number, url. <a href="https://formkit.com/essentials/validation" target="_blank">Документация <i class="fa fa-external-link"></i></a>
</p>
</div>
<!-- Label валидации -->
<div>
<div class="tw:flex tw:items-baseline tw:gap-2">
<label class="tw:block tw:text-sm tw:font-medium">Имя поля для ошибок</label>
<i
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help tw:text-xs"
v-tooltip.top="'Название поля, которое будет подставляться в текст ошибки валидации вместо системного имени.'"
></i>
</div>
<InputText
:modelValue="selectedField.validationLabel"
@update:modelValue="updateField(selectedField.id, { validationLabel: $event })"
class="tw:w-full"
placeholder="Например: Пароль"
/>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { Button, InputText, InputNumber, useConfirm } from 'primevue';
import { useFormFields } from './composables/useFormFields.js';
import { supportsPlaceholder, supportsOptions } from './utils/fieldHelpers.js';
import IconPicker from '@/components/FormBuilder/IconPicker.vue';
const {
formFields,
selectedFieldId,
removeField,
updateField,
addFieldOption,
removeFieldOption,
updateFieldOption,
isFieldNameUnique,
setFieldError // Импортируем метод для установки ошибок
} = useFormFields();
const confirm = useConfirm();
const nameError = ref(null);
const selectedField = computed(() => {
if (!selectedFieldId || !selectedFieldId.value || !formFields || !formFields.value) {
return null;
}
return formFields.value.find(f => f.id === selectedFieldId.value);
});
const hasPlaceholder = computed(() => {
if (!selectedField.value) return false;
return supportsPlaceholder(selectedField.value.$formkit);
});
const hasOptions = computed(() => {
if (!selectedField.value) return false;
return supportsOptions(selectedField.value.$formkit);
});
const isRangeOrNumber = computed(() => {
if (!selectedField.value) return false;
return ['range', 'number'].includes(selectedField.value.$formkit);
});
// Сбрасываем ошибку при смене поля
watch(selectedFieldId, () => {
nameError.value = null;
// Ошибки в глобальном состоянии сбрасываются только при исправлении
});
function onNameChange(newName) {
if (!selectedField.value) return;
// Убираем пробелы и спецсимволы, кроме _ и букв/цифр
// Хотя FormKit позволяет многое, лучше придерживаться стандартных правил переменных
const sanitized = newName.trim(); // .replace(/[^a-zA-Z0-9_]/g, '');
// Не будем жестко фильтровать, но проверим уникальность
if (!sanitized) {
nameError.value = 'Имя поля не может быть пустым';
setFieldError(selectedField.value.id, nameError.value);
return;
}
if (sanitized !== selectedField.value.name && !isFieldNameUnique(sanitized, selectedField.value.id)) {
nameError.value = 'Поле с таким именем уже существует';
setFieldError(selectedField.value.id, nameError.value);
return;
}
nameError.value = null;
setFieldError(selectedField.value.id, null);
updateField(selectedField.value.id, { name: sanitized });
}
function removeSelectedField(event) {
if (!selectedField.value) return;
confirm.require({
target: event.currentTarget,
message: `Вы уверены, что хотите удалить поле "${selectedField.value.label || selectedField.value.name}"?`,
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Да, удалить',
rejectLabel: 'Нет',
acceptClass: 'p-button-danger p-button-sm',
rejectClass: 'p-button-secondary p-button-sm p-button-text',
accept: () => {
removeField(selectedField.value.id);
}
});
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="tw:h-full tw:min-h-0 tw:flex tw:flex-col tw:bg-white tw:border tw:border-gray-200 tw:rounded-lg tw:overflow-hidden">
<!-- Заголовок -->
<div class="tw:p-4 tw:bg-[#f8f9fa] tw:border-b tw:border-gray-200 tw:font-bold tw:text-[#374151] tw:flex-shrink-0">
Доступные поля
</div>
<!-- Контент со скроллом -->
<div class="tw:flex-1 tw:min-h-0 tw:overflow-y-auto tw:p-4">
<draggable
:list="availableFields"
:group="{ name: 'fields', pull: 'clone', put: false }"
:sort="false"
:clone="cloneField"
item-key="type"
class="tw:space-y-2"
>
<template #item="{ element: field }">
<div
class="tw:p-3 tw:bg-gray-50 tw:border tw:border-gray-200 tw:rounded tw:cursor-move tw:hover:bg-gray-100 tw:transition-colors"
>
<div class="tw:flex tw:items-center tw:gap-2">
<i :class="field.icon" class="tw:text-gray-600"></i>
<span class="tw:text-sm tw:font-medium">{{ field.label }}</span>
</div>
</div>
</template>
</draggable>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import draggable from 'vuedraggable';
import { AVAILABLE_FIELDS } from './constants/availableFields.js';
import { useFormFields } from './composables/useFormFields.js';
const availableFields = ref(AVAILABLE_FIELDS);
const { generateFieldId } = useFormFields();
// Функция клонирования элемента при перетаскивании в канвас
function cloneField(field) {
const id = generateFieldId();
return {
id: id,
...field.defaultConfig,
name: field.defaultConfig.name || `field_${id.split('_')[1]}`,
};
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,476 @@
<template>
<div class="tw:flex tw:flex-col tw:h-[calc(100vh-200px)] tw:gap-4">
<!-- Popup подтверждения очистки -->
<ConfirmPopup group="clearForm" />
<!-- Диалог предупреждения при смене режима -->
<ConfirmDialog group="modeSwitch">
<template #message="{ message }">
<div class="tw:whitespace-pre-wrap tw:max-w-lg">{{ message.message }}</div>
</template>
</ConfirmDialog>
<!-- Панель инструментов -->
<div class="tw:flex tw:justify-start tw:items-center tw:pb-2 tw:border-b tw:border-gray-200">
<!-- Переключатель режимов -->
<SelectButton
:key="selectButtonKey"
:modelValue="activeMode"
:options="modes"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
@update:modelValue="handleModeChange"
>
<template #option="slotProps">
<i :class="slotProps.option.icon" class="tw:mr-2"></i>
<span>{{ slotProps.option.label }}</span>
</template>
</SelectButton>
</div>
<div class="tw:flex tw:flex-1 tw:gap-4 tw:overflow-hidden tw:min-h-0">
<!-- Режим визуального конструктора -->
<template v-if="activeMode === 'visual'">
<!-- Если форма кастомная, показываем предупреждение вместо редактора -->
<div v-if="isCustom" class="tw:flex-1 tw:flex tw:items-center tw:justify-center">
<div class="tw:max-w-2xl tw:p-8 tw:bg-yellow-50 tw:border-2 tw:border-yellow-200 tw:rounded-lg">
<div class="tw:flex tw:items-start tw:gap-4">
<i class="fa fa-exclamation-triangle tw:text-3xl tw:text-yellow-600"></i>
<div>
<h3 class="tw:text-lg tw:font-bold tw:text-yellow-800 tw:mb-2">
Форма является кастомной
</h3>
<p class="tw:text-yellow-700 tw:mb-4">
Эта форма была создана или изменена вручную в редакторе кода и не может быть отображена в визуальном редакторе.
</p>
<p class="tw:text-yellow-700 tw:mb-4">
Для работы с этой формой используйте режим "Код". Если вы хотите создать новую форму в визуальном редакторе, необходимо сбросить текущую форму.
</p>
<Button
label="Перейти в режим кода"
icon="fa fa-code"
@click="activeMode = 'code'"
class="tw:mr-2"
/>
<Button
label="Сбросить и создать новую"
icon="fa fa-trash"
severity="danger"
outlined
@click="showResetConfirmation"
/>
</div>
</div>
</div>
</div>
<!-- Обычный визуальный редактор для некстомных форм -->
<template v-else>
<!-- Панель доступных полей -->
<div class="tw:w-64 tw:flex-shrink-0 tw:h-full tw:overflow-hidden">
<FieldsPanel class="tw:h-full" />
</div>
<!-- Основная зона конструктора -->
<div class="tw:flex-1 tw:flex tw:gap-4 tw:overflow-hidden">
<!-- Зона формы -->
<div class="tw:flex-1 tw:flex tw:flex-col tw:border tw:border-gray-200 tw:rounded-lg tw:overflow-hidden tw:bg-white tw:relative">
<!-- Кнопка очистки (абсолютно позиционирована) -->
<div class="tw:absolute tw:top-4 tw:right-4 tw:z-20">
<Button
label="Очистить"
icon="fa fa-trash"
severity="danger"
size="small"
text
v-tooltip.left="'Удалить все поля и очистить форму'"
@click="clearForm"
/>
</div>
<!-- Контент (FormCanvas) занимает все оставшееся пространство -->
<div class="tw:flex-1 tw:overflow-y-auto tw:relative">
<FormCanvas class="tw:min-h-full" />
</div>
</div>
<!-- Панель настроек (справа) -->
<div class="tw:w-80 tw:flex-shrink-0 tw:h-full tw:overflow-y-auto">
<Panel class="tw:min-h-full">
<template #header>
<span>Настройки поля</span>
</template>
<FieldSettings />
</Panel>
</div>
</div>
</template>
</template>
<!-- Режим редактирования кода -->
<template v-if="activeMode === 'code'">
<CodeEditor
v-model="jsonCode"
:error="jsonError"
:is-custom="isCustom"
@change="handleJsonInput"
/>
</template>
<!-- Режим предпросмотра -->
<template v-if="activeMode === 'preview'">
<div class="tw:flex-1 tw:flex tw:justify-center tw:overflow-auto">
<div class="tw:w-full">
<div v-if="jsonError" class="tw:p-4 tw:bg-red-50 tw:border tw:border-red-200 tw:rounded tw:text-red-700">
<i class="fa fa-exclamation-circle tw:mr-2"></i>
Ошибка в схеме: {{ jsonError }}
</div>
<FormRenderer
v-else
:schema="formSchema"
submit-label="Отправить форму"
@submit="handleFormSubmit"
/>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
import { ref, provide, computed, onMounted, watch } from 'vue';
import { Button, Panel, SelectButton, useConfirm, ConfirmPopup, ConfirmDialog } from 'primevue';
import FieldsPanel from '@/components/FormBuilder/FieldsPanel.vue';
import FormCanvas from '@/components/FormBuilder/FormCanvas.vue';
import FieldSettings from '@/components/FormBuilder/FieldSettings.vue';
import FormRenderer from '@/components/FormBuilder/FormRenderer.vue';
import CodeEditor from '@/components/FormBuilder/CodeEditor.vue';
import { toastBus } from '@/utils/toastHelper';
import { saveRevision } from './utils/revisionManager.js';
import { createEmptySchema } from './utils/schemaParser.js';
const formFields = defineModel({
type: Array,
default: () => []
});
const isCustom = defineModel('isCustom', {
type: Boolean,
default: false
});
// Локальные состояния (не сохраняются в БД)
const dirtyFromCode = ref(false);
const lastSyncedSchema = ref(null);
const activeMode = ref('visual');
const jsonCode = ref('');
const jsonError = ref(null);
const confirm = useConfirm();
const selectButtonKey = ref(0);
// Алиас формы для сохранения ревизий (можно передавать через props, пока используем 'checkout')
const formAlias = 'checkout';
const modes = [
{ label: 'Визуальный', value: 'visual', icon: 'fa fa-th-large' },
{ label: 'Код', value: 'code', icon: 'fa fa-code' },
{ label: 'Предпросмотр', value: 'preview', icon: 'fa fa-eye' },
];
// Состояние выбранного поля
const selectedFieldId = ref(null);
// Состояние ошибок полей
const fieldErrors = ref({});
// Предоставляем состояние дочерним компонентам
provide('formFields', formFields);
provide('selectedFieldId', selectedFieldId);
provide('fieldErrors', fieldErrors);
// Схема формы для предпросмотра
const formSchema = computed(() => {
return formFields.value || [];
});
// Инициализация при монтировании
onMounted(() => {
initializeForm();
});
// Инициализация формы при загрузке из БД
function initializeForm() {
const schema = formFields.value || [];
// Устанавливаем lastSyncedSchema равным загруженной схеме
lastSyncedSchema.value = JSON.parse(JSON.stringify(schema));
// dirtyFromCode всегда false при загрузке
dirtyFromCode.value = false;
// Определяем начальный режим на основе isCustom
if (isCustom.value) {
activeMode.value = 'code';
jsonCode.value = JSON.stringify(schema, null, 2);
} else {
activeMode.value = 'visual';
jsonCode.value = JSON.stringify(schema, null, 2);
}
}
// Отслеживание изменений в визуальном редакторе
watch(formFields, (newSchema) => {
// Если мы в визуальном режиме и форма не кастомная, обновляем lastSyncedSchema
if (activeMode.value === 'visual' && !dirtyFromCode.value && !isCustom.value) {
lastSyncedSchema.value = JSON.parse(JSON.stringify(newSchema));
// Обновляем jsonCode для синхронизации
jsonCode.value = JSON.stringify(newSchema, null, 2);
}
}, { deep: true });
// Отслеживание изменений isCustom для переинициализации
watch(isCustom, () => {
// При изменении isCustom извне (например, при загрузке из БД) переинициализируем
if (activeMode.value === 'code' && !isCustom.value) {
// Если isCustom стал false, но мы в режиме кода, это значит форма была сброшена
// Синхронизируем состояния
lastSyncedSchema.value = JSON.parse(JSON.stringify(formFields.value));
dirtyFromCode.value = false;
}
});
function hasDuplicateNames(fields) {
if (!Array.isArray(fields)) return false;
const names = new Set();
for (const field of fields) {
if (field.name) {
if (names.has(field.name)) {
return true;
}
names.add(field.name);
}
}
return false;
}
function cancelModeSwitch() {
// Инкементируем ключ, чтобы принудительно перерисовать SelectButton с текущим activeMode
selectButtonKey.value++;
}
function handleModeChange(newMode) {
// Если пытаемся переключиться на тот же режим
if (newMode === activeMode.value) return;
// Если переключаемся ИЗ режима кода
if (activeMode.value === 'code') {
// Пытаемся распарсить JSON перед уходом
if (jsonError.value) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка JSON', detail: 'Исправьте ошибки в JSON перед переключением режима' });
cancelModeSwitch();
return;
}
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
toastBus.emit('show', { severity: 'error', summary: 'Ошибка валидации', detail: 'В схеме есть поля с одинаковыми именами (name). Исправьте их перед переключением.' });
cancelModeSwitch();
return;
}
// Если переключаемся в визуальный режим
if (newMode === 'visual') {
// Проверяем, нужно ли показывать предупреждение
const needsWarning = isCustom.value || dirtyFromCode.value;
if (needsWarning) {
// Сохраняем ревизию перед деструктивной операцией
saveRevision(formAlias, formFields.value, 'reset_to_visual');
// Показываем предупреждение
confirm.require({
group: 'modeSwitch',
header: 'Предупреждение',
message: isCustom.value
? 'Загруженная форма является кастомной и может содержать неподдерживаемые элементы.\n\nОткрытие визуального редактора приведет к полному сбросу формы. Все нестандартные настройки будут потеряны.'
: 'Форма была изменена вручную в редакторе кода.\n\nВизуальный редактор не поддерживает все конструкции, и для его открытия потребуется полностью сбросить форму и создать новую. Все нестандартные настройки будут потеряны.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Сбросить и открыть визуальный редактор',
rejectLabel: 'Отменить',
acceptClass: 'p-button-danger',
accept: () => {
resetFormToVisual();
activeMode.value = 'visual';
},
reject: () => {
cancelModeSwitch();
}
});
return;
} else {
// Если isCustom=false и dirtyFromCode=false, просто переключаемся
// Обновляем схему из кода
formFields.value = parsed;
lastSyncedSchema.value = JSON.parse(JSON.stringify(parsed));
dirtyFromCode.value = false;
}
} else if (newMode === 'preview') {
// Переход в Preview - обновляем модель из кода
formFields.value = parsed;
}
} catch (e) {
console.error('Ошибка парсинга при переключении:', e);
toastBus.emit('show', { severity: 'error', summary: 'Ошибка', detail: 'Некорректный JSON' });
cancelModeSwitch();
return;
}
}
// Если переключаемся В режим кода
if (newMode === 'code') {
// Обновляем jsonCode из текущей схемы
jsonCode.value = JSON.stringify(formFields.value, null, 2);
// Обновляем lastSyncedSchema, если мы пришли из визуального редактора
if (activeMode.value === 'visual') {
lastSyncedSchema.value = JSON.parse(JSON.stringify(formFields.value));
dirtyFromCode.value = false;
}
// isCustom не меняем автоматически при переходе в режим кода
// Он установится в true только когда пользователь реально изменит код (через handleJsonInput)
}
// Если переключаемся В визуальный режим из preview
if (newMode === 'visual' && activeMode.value === 'preview') {
// Никаких проверок, просто переключаемся
}
// Обновляем режим для успешных переходов
activeMode.value = newMode;
}
/**
* Сбрасывает форму для визуального редактора
*/
function resetFormToVisual() {
// Сохраняем ревизию (уже сохранена выше, но на всякий случай)
saveRevision(formAlias, formFields.value, 'reset_to_visual');
// Создаем пустую схему
const emptySchema = createEmptySchema();
// Обновляем все состояния
formFields.value = emptySchema;
selectedFieldId.value = null;
isCustom.value = false;
jsonCode.value = JSON.stringify(emptySchema, null, 2);
lastSyncedSchema.value = JSON.parse(JSON.stringify(emptySchema));
dirtyFromCode.value = false;
}
/**
* Показывает подтверждение сброса формы
*/
function showResetConfirmation() {
// Сохраняем ревизию перед деструктивной операцией
saveRevision(formAlias, formFields.value, 'reset_to_visual');
confirm.require({
group: 'modeSwitch',
header: 'Подтверждение сброса',
message: 'Вы уверены, что хотите сбросить кастомную форму и создать новую в визуальном редакторе?\n\nВсе текущие настройки будут потеряны.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Сбросить и создать новую',
rejectLabel: 'Отменить',
acceptClass: 'p-button-danger',
accept: () => {
resetFormToVisual();
activeMode.value = 'visual';
},
reject: () => {
// Отменяем действие
}
});
}
function handleJsonInput() {
try {
const parsed = JSON.parse(jsonCode.value);
if (hasDuplicateNames(parsed)) {
jsonError.value = 'Ошибка: найдены поля с одинаковыми именами (name)';
} else {
jsonError.value = null;
}
// Обновляем модель сразу, чтобы изменения не терялись
formFields.value = parsed;
// Проверяем, изменилась ли схема относительно lastSyncedSchema
if (lastSyncedSchema.value !== null) {
const currentSchemaStr = JSON.stringify(parsed);
const lastSyncedStr = JSON.stringify(lastSyncedSchema.value);
const hasChanges = currentSchemaStr !== lastSyncedStr;
dirtyFromCode.value = hasChanges;
// Если есть изменения и форма еще не кастомная, устанавливаем isCustom=true
if (hasChanges && !isCustom.value) {
isCustom.value = true;
}
} else {
// Если lastSyncedSchema еще не установлен, считаем что изменения есть
dirtyFromCode.value = true;
if (!isCustom.value) {
isCustom.value = true;
}
}
} catch (e) {
jsonError.value = e.message;
// При ошибке парсинга не обновляем dirtyFromCode и isCustom
}
}
function clearForm(event) {
confirm.require({
target: event.currentTarget,
group: 'clearForm',
message: 'Вы уверены, что хотите очистить форму? Все поля будут удалены.',
icon: 'fa fa-exclamation-triangle',
acceptLabel: 'Да, очистить',
rejectLabel: 'Нет',
acceptClass: 'p-button-danger p-button-sm',
rejectClass: 'p-button-secondary p-button-sm p-button-text',
accept: () => {
// Сохраняем ревизию перед очисткой
saveRevision(formAlias, formFields.value, 'clear_form');
const emptySchema = createEmptySchema();
formFields.value = emptySchema;
selectedFieldId.value = null;
jsonCode.value = JSON.stringify(emptySchema, null, 2);
lastSyncedSchema.value = JSON.parse(JSON.stringify(emptySchema));
dirtyFromCode.value = false;
// После очистки в визуальном редакторе форма не кастомная
isCustom.value = false;
toastBus.emit('show', { severity: 'success', summary: 'Успешно', detail: 'Форма очищена' });
}
});
}
function handleFormSubmit(data) {
console.log('Данные формы:', data);
toastBus.emit('show', { severity: 'success', summary: 'Форма отправлена', detail: 'Данные: ' + JSON.stringify(data, null, 2) });
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div
class="tw:min-h-full tw:w-full tw:flex tw:flex-col tw:items-center tw:p-8 blueprint-bg"
@click.self="handleBackgroundClick"
>
<div v-if="formFields.length === 0" class="tw:absolute tw:top-1/2 tw:left-1/2 tw:-translate-x-1/2 tw:-translate-y-1/2 tw:z-0 tw:text-center tw:text-gray-500 tw:py-12 tw:bg-white/80 tw:backdrop-blur-sm tw:rounded-xl tw:p-8 tw:shadow-sm tw:max-w-md tw:pointer-events-none">
<i class="fa fa-mouse-pointer tw:text-4xl tw:mb-4 tw:text-blue-500/50"></i>
<p class="tw:font-medium">Перетащите поля сюда, чтобы начать создавать форму</p>
</div>
<draggable
v-model="formFields"
group="fields"
item-key="id"
class="tw:w-full tw:max-w-2xl tw:space-y-4 tw:flex-1 tw:min-h-[300px] tw:relative tw:z-10 tw:pb-24"
ghost-class="ghost-field"
drag-class="drag-field"
@change="handleDragChange"
@click.self="handleBackgroundClick"
>
<template #item="{ element: field, index }">
<div
class="tw:relative tw:group tw:border-2 tw:rounded-lg tw:p-4 tw:bg-white tw:shadow-sm tw:cursor-pointer tw:transition-all"
:class="[
fieldErrors[field.id] ? 'tw:border-red-500 tw:ring-2 tw:ring-red-200' :
selectedFieldId === field.id ? 'tw:border-blue-500 tw:ring-2 tw:ring-blue-200' : 'tw:border-transparent hover:tw:border-blue-400'
]"
@click.stop="selectField(field.id)"
>
<!-- Иконка ошибки -->
<div
v-if="fieldErrors[field.id]"
class="tw:absolute tw:-left-3 tw:-top-3 tw:z-20 tw:bg-red-500 tw:text-white tw:rounded-full tw:w-6 tw:h-6 tw:flex tw:items-center tw:justify-center tw:shadow-sm"
v-tooltip.top="fieldErrors[field.id]"
>
<i class="fa fa-exclamation tw:text-xs"></i>
</div>
<!-- Кнопка удаления (справа за пределами блока, видна при выборе) -->
<div
class="tw:absolute tw:-right-12 tw:top-1/2 tw:-translate-y-1/2 tw:z-10 tw:transition-opacity tw:duration-200"
:class="selectedFieldId === field.id ? 'tw:opacity-100' : 'tw:opacity-0 tw:pointer-events-none'"
>
<Button
@click.stop="removeField(field.id)"
icon="fa fa-trash"
severity="danger"
rounded
size="small"
v-tooltip.right="'Удалить поле'"
class="!tw:shadow-md !tw:w-9 !tw:h-9 !tw:p-0 tw:flex tw:items-center tw:justify-center hover:!tw:bg-red-600"
/>
</div>
<!-- Оверлей для перехвата кликов поверх disabled инпутов -->
<div class="tw:absolute tw:inset-0 tw:z-[1] tw:bg-transparent"></div>
<!-- Предпросмотр поля -->
<div class="tw:relative tw:z-0">
<FormKit
v-if="field.$formkit"
:key="`${field.id}-${field.prefixIcon}-${field.suffixIcon}`"
:type="field.$formkit"
v-bind="getFieldProps(field)"
:name="field.name || `field_${field.id}`"
/>
<div v-else class="tw:text-red-500 tw:text-sm">
<i class="fa fa-exclamation-triangle tw:mr-2"></i>
Поле без типа $formkit
</div>
</div>
</div>
</template>
</draggable>
</div>
</template>
<script setup>
import { FormKit } from '@formkit/vue';
import { Button } from 'primevue';
import draggable from 'vuedraggable';
import { useFormFields } from './composables/useFormFields.js';
import { getFieldProps } from './utils/fieldHelpers.js';
import { toastBus } from '@/utils/toastHelper';
// Используем composable для работы с полями
const {
formFields,
selectedFieldId,
fieldErrors,
selectField,
removeField,
isFieldNameUnique
} = useFormFields();
function handleDragChange(evt) {
if (evt.added) {
const addedField = evt.added.element;
// Проверяем уникальность имени
if (!isFieldNameUnique(addedField.name, addedField.id)) {
// Удаляем дубликат
removeField(addedField.id);
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка добавления',
detail: `Поле с именем "${addedField.name}" уже добавлено в форму.`
});
return;
}
selectField(addedField.id);
}
}
function handleBackgroundClick() {
// Сбрасываем выбор, если есть выбранный элемент
if (selectedFieldId.value) {
selectedFieldId.value = null;
}
}
</script>
<style scoped>
.blueprint-bg {
background-color: #e2e8f0;
background-image: radial-gradient(#cbd5e1 1px, transparent 1px);
background-size: 10px 10px;
}
.ghost-field {
opacity: 0.5;
background-color: #eff6ff;
border-color: #93c5fd;
border-style: dashed;
}
.drag-field {
opacity: 1;
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
transform: scale(1.05);
}
</style>

View File

@@ -0,0 +1,57 @@
<template>
<div class="tw:flex tw:justify-center tw:items-start tw:p-8">
<!-- Phone Mockup -->
<div class="tw:relative tw:inline-grid tw:justify-items-center tw:bg-black tw:border-[2.5px] tw:border-gray-600 tw:rounded-[32.5px] tw:p-[3px] tw:overflow-hidden tw:w-full tw:max-w-[280px]" style="aspect-ratio: 462/978;">
<!-- Camera -->
<div class="tw:absolute tw:top-[3%] tw:left-1/2 tw:-translate-x-1/2 tw:z-10 tw:bg-black tw:rounded-[8.5px] tw:w-[28%] tw:h-[3.7%]"></div>
<!-- Display -->
<div class="tw:relative tw:rounded-[27px] tw:w-full tw:h-full tw:overflow-hidden tw:bg-gray-100">
<div class="tw:pt-15 tw:px-2 tw:pb-2 tw:overflow-y-auto tw:max-h-full tw:h-full">
<FormKit
type="form"
@submit="handleSubmit"
:submit-label="submitLabel"
outer-class="tw:space-y-4"
v-model="form"
>
<FormKitSchema :schema="schema" :data="data"/>
</FormKit>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { FormKit, FormKitSchema } from '@formkit/vue';
import {reactive, ref} from "vue";
const form = ref({});
const data = reactive(form);
const props = defineProps({
schema: {
type: Array,
required: true,
default: () => [],
},
submitLabel: {
type: String,
default: 'Отправить',
},
});
const emit = defineEmits(['submit']);
function handleSubmit(data) {
emit('submit', data);
}
</script>
<style scoped>
::v-deep(ul.formkit-messages) {
margin-bottom: 0;
padding-left: 0;
}
</style>

View File

@@ -0,0 +1,124 @@
<template>
<div>
<div class="tw:flex tw:gap-2">
<div
class="tw:flex-1 tw:border tw:border-gray-300 tw:rounded-md tw:p-2 tw:flex tw:items-center tw:gap-2 tw:cursor-pointer hover:tw:bg-gray-50 tw:min-h-[42px]"
@click="visible = true"
>
<div v-if="modelValue" class="tw:w-5 tw:h-5 tw:text-gray-600 tw:flex tw:items-center tw:justify-center" v-html="getIconSvg(modelValue)"></div>
<span v-if="modelValue" class="tw:text-sm tw:text-gray-700">{{ modelValue }}</span>
<span v-else class="tw:text-sm tw:text-gray-400">Выберите иконку...</span>
</div>
<Button
v-if="modelValue"
icon="fa fa-times"
text
rounded
severity="secondary"
@click="emit('update:modelValue', null)"
v-tooltip="'Очистить'"
/>
</div>
<Dialog
v-model:visible="visible"
modal
header="Выберите иконку"
:style="{ width: '50vw', maxWidth: '600px' }"
:breakpoints="{ '960px': '75vw', '640px': '90vw' }"
>
<div class="tw:flex tw:flex-col tw:gap-4">
<IconField>
<InputIcon class="fa fa-search" />
<InputText v-model="searchQuery" placeholder="Поиск иконки..." class="tw:w-full" />
</IconField>
<div class="tw:grid tw:grid-cols-6 sm:tw:grid-cols-8 md:tw:grid-cols-12 tw:gap-1 tw:max-h-[400px] tw:overflow-y-auto tw:p-1">
<div
v-for="iconName in filteredIcons"
:key="iconName"
class="tw:flex tw:flex-col tw:items-center tw:justify-between tw:p-1 tw:border tw:rounded tw:cursor-pointer hover:tw:bg-blue-50 hover:tw:border-blue-200 tw:transition-colors tw:aspect-square"
:class="{ 'tw:bg-blue-100 tw:border-blue-400': modelValue === iconName }"
@click="selectIcon(iconName)"
>
<div class="tw:flex-1 tw:flex tw:items-center tw:justify-center tw:w-full tw:min-h-0 tw:text-gray-700 tw:[&>svg]:w-15 tw:[&>svg]:h-15" v-html="getIconSvg(iconName)"></div>
<span class="tw:text-[9px] tw:text-gray-500 tw:truncate tw:w-full tw:text-center tw:mt-0.5 tw:flex-shrink-0" :title="iconName">{{ iconName }}</span>
</div>
<div v-if="filteredIcons.length === 0" class="tw:col-span-full tw:text-center tw:text-gray-500 tw:py-8">
Ничего не найдено
</div>
</div>
</div>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import * as icons from '@formkit/icons';
import {Button, IconField, InputIcon, InputText, Dialog} from 'primevue';
const props = defineProps({
modelValue: {
type: String,
default: null
}
});
const emit = defineEmits(['update:modelValue']);
const visible = ref(false);
const searchQuery = ref('');
// Собираем все иконки из экспорта @formkit/icons
// genesisIcons входит сюда как подмножество, но также там есть и другие наборы (например, feather, fontawesome и т.д. если они были бы установлены,
// но в стандартном пакете @formkit/icons есть только genesis, application, brand, currency, direction, file, input, payment, social, etc.)
// Пройдемся по всему объекту icons и соберем все строки-SVG.
// Но структура экспорта @formkit/icons может быть такой:
// export { genesisIcons } ...
// export { ... }
// Реально пакет содержит много наборов.
// Давайте соберем их все в один плоский список.
const allIconsMap = {};
// Функция для рекурсивного/плоского сбора иконок, если они сгруппированы
Object.entries(icons).forEach(([key, value]) => {
if (typeof value === 'string' && value.startsWith('<svg')) {
// Это прямая иконка (если вдруг)
allIconsMap[key] = value;
} else if (typeof value === 'object' && value !== null) {
// Это группа иконок (например genesisIcons)
Object.entries(value).forEach(([iconName, svgContent]) => {
if (typeof svgContent === 'string' && svgContent.startsWith('<svg')) {
// Если имя уже есть, не перезаписываем или перезаписываем - не важно, главное чтобы был доступ.
// Лучше сохранить оригинальное имя.
allIconsMap[iconName] = svgContent;
}
});
}
});
const allIconNames = Object.keys(allIconsMap).sort();
const filteredIcons = computed(() => {
if (!searchQuery.value) return allIconNames;
const lower = searchQuery.value.toLowerCase();
return allIconNames.filter(name => name.toLowerCase().includes(lower));
});
function getIconSvg(iconName) {
return allIconsMap[iconName];
}
function selectIcon(iconName) {
emit('update:modelValue', iconName);
visible.value = false;
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,235 @@
import { inject, ref } from 'vue';
/**
* Composable для работы с полями формы
*/
export function useFormFields() {
const formFields = inject('formFields');
const selectedFieldId = inject('selectedFieldId');
// Глобальное состояние ошибок полей { [fieldId]: 'Текст ошибки' }
// Используем provide/inject если нужно шарить между компонентами, но пока можно и локально,
// если этот composable используется в provide в родителе.
// В данном случае мы просто добавим ref, но так как composable вызывается в разных местах,
// состояние не будет общим. Нужно вынести состояние выше или использовать provide/inject для ошибок.
// Но для простоты, раз у нас FormBuilder провайдит formFields, добавим и errors туда.
const fieldErrors = inject('fieldErrors', ref({}));
/**
* Выбирает поле по ID
*/
function selectField(fieldId) {
if (selectedFieldId) {
selectedFieldId.value = fieldId;
}
}
/**
* Устанавливает ошибку для поля
*/
function setFieldError(fieldId, error) {
if (!fieldErrors.value) return;
if (error) {
fieldErrors.value[fieldId] = error;
} else {
delete fieldErrors.value[fieldId];
}
}
/**
* Удаляет поле по ID
*/
function removeField(fieldId) {
if (!formFields || !formFields.value) return;
formFields.value = formFields.value.filter(f => f.id !== fieldId);
// Очищаем ошибку при удалении
if (fieldErrors.value[fieldId]) {
delete fieldErrors.value[fieldId];
}
if (selectedFieldId && selectedFieldId.value === fieldId) {
selectedFieldId.value = null;
}
}
/**
* Перемещает поле вверх или вниз (больше не нужно с vuedraggable, но оставим для совместимости/ручного управления)
*/
function moveField(index, direction) {
if (!formFields || !formFields.value) return;
const newFields = [...formFields.value];
if (direction === 'up' && index > 0) {
[newFields[index], newFields[index - 1]] =
[newFields[index - 1], newFields[index]];
formFields.value = newFields;
} else if (direction === 'down' && index < newFields.length - 1) {
[newFields[index], newFields[index + 1]] =
[newFields[index + 1], newFields[index]];
formFields.value = newFields;
}
}
/**
* Генерирует уникальный ID для поля
*/
function generateFieldId() {
let maxId = 0;
if (formFields && formFields.value) {
formFields.value.forEach(field => {
const match = field.id?.match(/field_(\d+)/);
if (match) {
const idNum = parseInt(match[1]);
if (idNum > maxId) maxId = idNum;
}
});
}
return `field_${maxId + 1}_${Date.now()}`;
}
/**
* Проверяет, уникально ли имя поля
*/
function isFieldNameUnique(name, excludeId = null) {
if (!formFields || !formFields.value) return true;
return !formFields.value.some(field =>
field.name === name && field.id !== excludeId
);
}
/**
* Генерирует уникальное имя для поля
*/
function generateUniqueName(baseName) {
let name = baseName;
let counter = 1;
while (!isFieldNameUnique(name)) {
name = `${baseName}_${counter}`;
counter++;
}
return name;
}
/**
* Добавляет новое поле в форму
*/
function addField(fieldConfig, targetIndex = null) {
if (!formFields || !formFields.value) return null;
const id = generateFieldId();
// Генерируем уникальное имя на основе конфига или ID, если имя не задано
let initialName = fieldConfig.name || `field_${id.split('_')[1]}`;
const uniqueName = generateUniqueName(initialName);
const newField = {
id,
...fieldConfig,
name: uniqueName,
};
const newFields = [...formFields.value];
if (targetIndex !== null && targetIndex >= 0) {
newFields.splice(targetIndex + 1, 0, newField);
} else {
newFields.push(newField);
}
formFields.value = newFields;
selectField(newField.id);
return newField;
}
/**
* Обновляет свойства поля
*/
function updateField(fieldId, updates) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const newFields = [...formFields.value];
newFields[index] = { ...newFields[index], ...updates };
formFields.value = newFields;
}
}
/**
* Добавляет опцию к полю
*/
function addFieldOption(fieldId) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
const options = field.options ? [...field.options] : [];
options.push({
label: 'Новая опция',
value: `option_${options.length + 1}`,
});
updateField(fieldId, { options });
}
}
/**
* Удаляет опцию у поля
*/
function removeFieldOption(fieldId, optionIndex) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
if (!field.options) return;
const options = [...field.options];
options.splice(optionIndex, 1);
updateField(fieldId, { options });
}
}
/**
* Обновляет опцию поля
*/
function updateFieldOption(fieldId, optionIndex, key, value) {
if (!formFields || !formFields.value) return;
const index = formFields.value.findIndex(f => f.id === fieldId);
if (index !== -1) {
const field = formFields.value[index];
if (!field.options) return;
const options = [...field.options];
options[optionIndex] = { ...options[optionIndex], [key]: value };
updateField(fieldId, { options });
}
}
return {
formFields,
selectedFieldId,
fieldErrors, // Экспортируем ошибки
selectField,
removeField,
moveField,
addField,
updateField,
generateFieldId,
isFieldNameUnique,
generateUniqueName,
addFieldOption,
removeFieldOption,
updateFieldOption,
setFieldError, // Экспортируем метод установки ошибки
};
}

View File

@@ -0,0 +1,301 @@
// Доступные типы полей для конструктора
export const AVAILABLE_FIELDS = [
// Поля заказа
{
type: 'firstname_order',
label: 'Имя (Заказ)',
icon: 'fa fa-user',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'firstname',
label: 'Имя',
placeholder: 'Например: Иван',
help: 'Введите ваше имя',
validation: 'required|length:0,32',
prefixIcon: "avatarMan",
locked: true,
}
},
{
type: 'lastname_order',
label: 'Фамилия (Заказ)',
icon: 'fa fa-user',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'lastname',
label: 'Фамилия',
placeholder: 'Например: Иванов',
help: 'Введите вашу фамилию',
validation: 'required|length:0,32',
prefixIcon: "avatarMan",
locked: true,
}
},
{
type: 'email_order',
label: 'Email (Заказ)',
icon: 'fa fa-envelope',
group: 'order',
defaultConfig: {
$formkit: 'email',
name: 'email',
label: 'E-mail',
placeholder: 'Например: example@mail.com',
help: 'Введите ваш электронный адрес.',
validation: 'required|email|length:0,96',
prefixIcon: "email",
locked: true,
}
},
{
type: 'telephone_order',
label: 'Телефон (Заказ)',
icon: 'fa fa-phone',
group: 'order',
defaultConfig: {
$formkit: 'tel',
name: 'telephone',
label: 'Телефон',
placeholder: 'Например: +7 (999) 000-00-00',
validation: 'required|length:0,32',
help: 'Введите ваш номер телефона.',
prefixIcon: "telephone",
locked: true,
}
},
{
type: 'comment_order',
label: 'Комментарий (Заказ)',
icon: 'fa fa-comment',
group: 'order',
defaultConfig: {
$formkit: 'textarea',
name: 'comment',
label: 'Комментарий к заказу',
placeholder: 'Например: Домофон не работает',
help: 'Дополнительная информация к заказу',
validation: 'length:0,5000',
locked: true,
}
},
{
type: 'shipping_address_1_order',
label: 'Адрес доставки (Заказ)',
icon: 'fa fa-map-marker',
group: 'order',
defaultConfig: {
$formkit: 'textarea',
name: 'shipping_address_1',
label: 'Адрес доставки',
placeholder: 'Например: ул. Ленина, д. 1, кв. 10',
help: 'Укажите улицу, дом и квартиру',
validation: 'required|length:0,128',
locked: true,
}
},
{
type: 'shipping_city_order',
label: 'Город доставки (Заказ)',
icon: 'fa fa-building',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_city',
label: 'Город',
placeholder: 'Например: Москва',
help: 'Город доставки',
validation: 'required|length:0,128',
locked: true,
}
},
{
type: 'shipping_postcode_order',
label: 'Индекс доставки (Заказ)',
icon: 'fa fa-map-pin',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_postcode',
label: 'Почтовый индекс',
placeholder: 'Например: 101000',
help: 'Почтовый индекс',
validation: 'length:0,10',
locked: true,
}
},
{
type: 'shipping_zone_order',
label: 'Регион доставки (Заказ)',
icon: 'fa fa-map',
group: 'order',
defaultConfig: {
$formkit: 'text',
name: 'shipping_zone',
label: 'Регион / Область',
placeholder: 'Например: Московская область',
help: 'Регион или область',
validation: 'length:0,128',
locked: true,
}
},
{
type: 'payment_method_order',
label: 'Способ оплаты (Заказ)',
icon: 'fa fa-money',
group: 'order',
defaultConfig: {
$formkit: 'select',
label: "Способ оплаты заказа",
options: [
{
"label": "Наличными в пункте выдачи",
"value": "Наличными в пункте выдачи"
},
{
"label": "Наличными курьеру",
"value": "Наличными курьеру"
},
{
"label": "Картой курьеру",
"value": "Картой курьеру"
},
{
"label": "В кредит",
"value": "В кредит"
}
],
validation: "required",
name: "payment_method",
prefixIcon: "mastercard",
validationLabel: "Способ оплаты",
help: "Выберите способ оплаты заказа",
locked: true,
}
},
{
type: 'text',
label: 'Текстовое поле',
icon: 'fa fa-font',
defaultConfig: {
$formkit: 'text',
label: 'Текстовое поле',
placeholder: 'Введите текст',
validation: 'required',
}
},
{
type: 'textarea',
label: 'Многострочный текст',
icon: 'fa fa-align-left',
defaultConfig: {
$formkit: 'textarea',
label: 'Многострочный текст',
placeholder: 'Введите текст',
validation: '',
}
},
{
type: 'number',
label: 'Число',
icon: 'fa fa-hashtag',
group: 'general',
defaultConfig: {
$formkit: 'number',
label: 'Число',
placeholder: '0',
validation: '',
}
},
{
type: 'url',
label: 'URL',
icon: 'fa fa-link',
group: 'general',
defaultConfig: {
$formkit: 'url',
label: 'Ссылка',
placeholder: 'https://example.com',
validation: 'url',
}
},
{
type: 'select',
label: 'Выпадающий список',
icon: 'fa fa-list',
group: 'general',
defaultConfig: {
$formkit: 'select',
label: 'Выпадающий список',
options: [
{label: 'Вариант 1', value: 'option1'},
{label: 'Вариант 2', value: 'option2'},
],
validation: 'required',
}
},
{
type: 'checkbox',
label: 'Чекбокс',
icon: 'fa fa-check-square',
group: 'general',
defaultConfig: {
$formkit: 'checkbox',
label: 'Чекбокс',
validation: '',
}
},
{
type: 'radio',
label: 'Радио кнопки',
icon: 'fa fa-dot-circle',
group: 'general',
defaultConfig: {
$formkit: 'radio',
label: 'Радио кнопки',
options: [
{label: 'Вариант 1', value: 'option1'},
{label: 'Вариант 2', value: 'option2'},
],
validation: 'required',
}
},
{
type: 'date',
label: 'Дата',
icon: 'fa fa-calendar',
group: 'general',
defaultConfig: {
$formkit: 'date',
label: 'Дата',
validation: 'required',
}
},
{
type: 'color',
label: 'Цвет',
icon: 'fa fa-palette',
group: 'general',
defaultConfig: {
$formkit: 'color',
label: 'Выберите цвет',
value: '#000000',
validation: '',
}
},
{
type: 'range',
label: 'Диапазон',
icon: 'fa fa-sliders-h',
group: 'general',
defaultConfig: {
$formkit: 'range',
label: 'Диапазон',
min: 0,
max: 100,
step: 1,
validation: '',
}
},
];

View File

@@ -0,0 +1,53 @@
import { PLACEHOLDER_FIELD_TYPES, OPTIONS_FIELD_TYPES } from './fieldTypes.js';
/**
* Получает placeholder для поля (только для поддерживаемых типов)
* @param {Object} field - Объект поля
* @returns {string|undefined} - Placeholder или undefined
*/
export function getPlaceholder(field) {
const type = field.$formkit;
if (!PLACEHOLDER_FIELD_TYPES.includes(type)) {
return undefined;
}
if (field.placeholder && field.placeholder.trim()) {
return field.placeholder.trim();
}
return undefined;
}
/**
* Получает props для поля FormKit для отображения в редакторе
* @param {Object} field - Объект поля
* @returns {Object} - Объект с props для FormKit
*/
export function getFieldProps(field) {
// Создаем копию, исключая служебные поля, которые мы передаем отдельно или не хотим передавать
const { $formkit: _$formkit, id: _id, ...rest } = field;
const props = { ...rest };
// Опции для select и radio
// FormKit принимает массив объектов { label, value }, так что преобразование может не понадобиться
// если формат совпадает. В availableFields мы используем { label, value }.
return props;
}
/**
* Проверяет, поддерживает ли тип поля placeholder
* @param {string} fieldType - Тип поля ($formkit)
* @returns {boolean}
*/
export function supportsPlaceholder(fieldType) {
return PLACEHOLDER_FIELD_TYPES.includes(fieldType);
}
/**
* Проверяет, поддерживает ли тип поля опции
* @param {string} fieldType - Тип поля ($formkit)
* @returns {boolean}
*/
export function supportsOptions(fieldType) {
return OPTIONS_FIELD_TYPES.includes(fieldType);
}

View File

@@ -0,0 +1,30 @@
// Типы полей, которые поддерживают placeholder
export const PLACEHOLDER_FIELD_TYPES = [
'text',
'email',
'textarea',
'number',
'tel',
'url',
'password',
'search'
];
// Типы полей, которые поддерживают опции
export const OPTIONS_FIELD_TYPES = ['select', 'radio'];
// Все поддерживаемые типы полей
export const FIELD_TYPES = {
TEXT: 'text',
EMAIL: 'email',
TEXTAREA: 'textarea',
SELECT: 'select',
CHECKBOX: 'checkbox',
RADIO: 'radio',
DATE: 'date',
NUMBER: 'number',
TEL: 'tel',
URL: 'url',
COLOR: 'color',
RANGE: 'range',
};

View File

@@ -0,0 +1,119 @@
/**
* Утилита для управления ревизиями схемы формы
* Сохраняет предыдущие версии перед деструктивными операциями
*/
const REVISION_STORAGE_KEY = 'formBuilder_revisions';
const MAX_REVISIONS = 10;
/**
* Сохраняет ревизию схемы перед деструктивной операцией
* @param {string} formAlias - Алиас формы (например, 'checkout')
* @param {Array} schema - Схема формы для сохранения
* @param {string} reason - Причина сохранения (например, 'reset_to_visual')
*/
export function saveRevision(formAlias, schema, reason = 'unknown') {
try {
const revisions = getRevisions(formAlias);
const revision = {
id: `rev_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
schema: JSON.parse(JSON.stringify(schema)), // Deep clone
timestamp: new Date().toISOString(),
reason,
};
revisions.unshift(revision);
// Ограничиваем количество ревизий
if (revisions.length > MAX_REVISIONS) {
revisions.splice(MAX_REVISIONS);
}
const storage = getAllRevisions();
storage[formAlias] = revisions;
localStorage.setItem(REVISION_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.error('Ошибка сохранения ревизии:', error);
}
}
/**
* Получает все ревизии для формы
* @param {string} formAlias - Алиас формы
* @returns {Array} Массив ревизий
*/
export function getRevisions(formAlias) {
try {
const storage = getAllRevisions();
return storage[formAlias] || [];
} catch (error) {
console.error('Ошибка получения ревизий:', error);
return [];
}
}
/**
* Получает все ревизии для всех форм
* @returns {Object} Объект с ревизиями по алиасам форм
*/
function getAllRevisions() {
try {
const stored = localStorage.getItem(REVISION_STORAGE_KEY);
return stored ? JSON.parse(stored) : {};
} catch (error) {
console.error('Ошибка чтения ревизий из localStorage:', error);
return {};
}
}
/**
* Восстанавливает схему из ревизии
* @param {string} formAlias - Алиас формы
* @param {string} revisionId - ID ревизии
* @returns {Array|null} Схема формы или null, если ревизия не найдена
*/
export function restoreRevision(formAlias, revisionId) {
try {
const revisions = getRevisions(formAlias);
const revision = revisions.find(r => r.id === revisionId);
if (revision) {
return JSON.parse(JSON.stringify(revision.schema)); // Deep clone
}
return null;
} catch (error) {
console.error('Ошибка восстановления ревизии:', error);
return null;
}
}
/**
* Удаляет ревизию
* @param {string} formAlias - Алиас формы
* @param {string} revisionId - ID ревизии
*/
export function deleteRevision(formAlias, revisionId) {
try {
const revisions = getRevisions(formAlias);
const filtered = revisions.filter(r => r.id !== revisionId);
const storage = getAllRevisions();
storage[formAlias] = filtered;
localStorage.setItem(REVISION_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.error('Ошибка удаления ревизии:', error);
}
}
/**
* Очищает все ревизии для формы
* @param {string} formAlias - Алиас формы
*/
export function clearRevisions(formAlias) {
try {
const storage = getAllRevisions();
delete storage[formAlias];
localStorage.setItem(REVISION_STORAGE_KEY, JSON.stringify(storage));
} catch (error) {
console.error('Ошибка очистки ревизий:', error);
}
}

View File

@@ -0,0 +1,23 @@
/**
* Утилита для работы со схемами форм
* Упрощенная логика: если isCustom=true, схема несовместима с визуальным редактором
*/
/**
* Проверяет совместимость схемы с визуальным редактором
* @param {boolean} isCustom - Флаг кастомной формы
* @returns {boolean} true если схема совместима, false если нет
*/
export function isSchemaCompatible(isCustom) {
// Если форма кастомная, она несовместима с визуальным редактором
return !isCustom;
}
/**
* Создает пустую схему для визуального редактора
* @returns {Array} Пустая схема
*/
export function createEmptySchema() {
return [];
}

View File

@@ -0,0 +1,191 @@
<template>
<div>
<DataTable
:value="logs.logs"
:loading="logs.loading"
paginator
:rows="15"
:rowsPerPageOptions="[15, 50, 100, 200]"
showGridlines
stripedRows
size="small"
sortField="datetime_raw"
:sortOrder="-1"
removableSort
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:currentPageReportTemplate="`Показано {first} - {last} из {totalRecords} записей`"
>
<template #header>
<div class="tw:flex tw:items-center tw:justify-between tw:gap-2">
<span class="tw:text-sm tw:text-gray-600">Выводятся последние 100 событий</span>
<Button
icon="fa fa-refresh"
@click="logs.fetchLogsFromServer()"
v-tooltip.top="'Обновить журнал'"
size="small"
:loading="logs.loading"
/>
</div>
</template>
<Column header="Действия" :exportable="false" headerStyle="width: 5rem">
<template #body="{ data }">
<Button
icon="fa fa-eye"
severity="secondary"
text
rounded
size="small"
@click="openLogDetails(data)"
v-tooltip.top="'Просмотреть подробности'"
/>
</template>
</Column>
<Column field="datetime" header="Дата и время" sortable sortField="datetime_raw" style="min-width: 180px">
<template #body="{ data }">
<span v-if="data.datetime">{{ data.datetime }}</span>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="level" header="Уровень" style="min-width: 100px">
<template #body="{ data }">
<Badge
v-if="data.level"
:value="data.level"
:severity="getLevelSeverity(data.level)"
/>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="channel" header="Канал" style="min-width: 120px">
<template #body="{ data }">
<span v-if="data.channel">{{ data.channel }}</span>
<span v-else class="tw:text-gray-400"></span>
</template>
</Column>
<Column field="message" header="Сообщение" style="min-width: 300px">
<template #body="{ data }">
<div class="tw:break-words">{{ data.message }}</div>
</template>
</Column>
</DataTable>
<Dialog
v-model:visible="showLogDetailsDialog"
modal
header="Подробности лога"
:style="{ width: '800px', maxWidth: '90vw' }"
:closable="true"
:dismissableMask="true"
>
<div v-if="selectedLog" class="tw:space-y-4">
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Дата и время:</label>
<div class="tw:text-sm">
<div v-if="selectedLog.datetime">{{ selectedLog.datetime }}</div>
<div v-if="selectedLog.datetime_raw && selectedLog.datetime_raw !== selectedLog.datetime" class="tw:text-gray-500 tw:text-xs tw:mt-1">
({{ selectedLog.datetime_raw }})
</div>
<span v-if="!selectedLog.datetime" class="tw:text-gray-400"></span>
</div>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Уровень:</label>
<span
v-if="selectedLog.level"
:class="{
'tw:text-red-600 tw:font-bold': selectedLog.level === 'ERROR' || selectedLog.level === 'CRITICAL',
'tw:text-orange-600': selectedLog.level === 'WARNING',
'tw:text-blue-600': selectedLog.level === 'INFO',
'tw:text-gray-600': selectedLog.level === 'DEBUG',
}"
class="tw:text-sm"
>
{{ selectedLog.level }}
</span>
<span v-else class="tw:text-gray-400 tw:text-sm"></span>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Канал:</label>
<span v-if="selectedLog.channel" class="tw:text-sm">{{ selectedLog.channel }}</span>
<span v-else class="tw:text-gray-400 tw:text-sm"></span>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Сообщение:</label>
<div class="tw:text-sm tw:bg-gray-50 tw:p-3 tw:rounded tw:break-words tw:whitespace-pre-wrap">{{ selectedLog.message || '—' }}</div>
</div>
<div v-if="selectedLog.context">
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Контекст:</label>
<pre class="tw:text-xs tw:bg-gray-100 tw:p-3 tw:rounded tw:overflow-auto tw:max-h-96 tw:border tw:border-gray-200 tw:whitespace-pre-wrap tw:break-words">{{ JSON.stringify(selectedLog.context, null, 2) }}</pre>
</div>
<div>
<label class="tw:block tw:font-semibold tw:mb-1 tw:text-sm">Исходная строка:</label>
<pre class="tw:text-xs tw:bg-gray-100 tw:p-3 tw:rounded tw:overflow-auto tw:max-h-48 tw:border tw:border-gray-200 tw:whitespace-pre-wrap tw:break-words">{{ selectedLog.raw }}</pre>
</div>
</div>
<template #footer>
<Button
label="Закрыть"
icon="fa fa-times"
severity="secondary"
@click="closeLogDetailsDialog"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { useLogsStore } from "@/stores/logs.js";
import DataTable from "primevue/datatable";
import Column from "primevue/column";
import Button from "primevue/button";
import Dialog from "primevue/dialog";
import Badge from "primevue/badge";
const logs = useLogsStore();
const showLogDetailsDialog = ref(false);
const selectedLog = ref(null);
function openLogDetails(log) {
selectedLog.value = log;
showLogDetailsDialog.value = true;
}
function closeLogDetailsDialog() {
showLogDetailsDialog.value = false;
selectedLog.value = null;
}
function getLevelSeverity(level) {
switch (level) {
case 'ERROR':
case 'CRITICAL':
return 'danger';
case 'WARNING':
return 'warn';
case 'INFO':
return 'info';
case 'DEBUG':
return 'secondary';
default:
return null;
}
}
onMounted(async () => logs.fetchLogsFromServer());
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,63 @@
<template>
<div>
<div class="tw:flex tw:justify-between tw:items-start">
<div>
<h3 class="p-card-title">
{{ title }}
</h3>
<slot/>
</div>
<div class="tw:flex tw:items-center tw:gap-2">
<Button
icon="fa fa-cog"
severity="contrast"
rounded
text
@click="$emit('onShowSettings')"
/>
<Button
icon="fa fa-trash"
severity="danger"
rounded
text
@click="confirmedRemove($event)"
/>
</div>
</div>
</div>
</template>
<script setup>
import {Button, useConfirm} from "primevue";
const props = defineProps({
title: {
type: String,
default: null,
},
});
const confirm = useConfirm();
const emit = defineEmits(['onRemove', 'onShowSettings']);
function confirmedRemove(event) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'Удалить блок?',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Удалить',
severity: 'danger'
},
accept: () => emit('onRemove'),
});
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<BaseBlock
:title="`Топ категорий - ${value.title || 'Без заголовка'}`"
@onRemove="$emit('onRemove')"
@onShowSettings="$emit('onShowSettings')"
>
<div class="tw:mt-3 tw:text-sm tw:dark:text-slate-300 tw:space-y-1">
<div><span class="tw:font-bold tw:dark:text-slate-200">Описание:</span> {{
value.description
}}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Кол-во категорий:</span>
{{ value.data.count }}
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,31 @@
<template>
<BaseBlock
:title="`Карусель товаров - ${value.title || 'Без заголовка'}`"
@onRemove="$emit('onRemove')"
@onShowSettings="$emit('onShowSettings')"
>
<div class="tw:mt-3 tw:text-sm tw:dark:text-slate-300 tw:space-y-1">
<div>
<span class="tw:font-bold tw:dark:text-slate-200">Описание:</span>
{{ value.description }}
</div>
<div>
<span class="tw:font-bold tw:dark:text-slate-200 tw:mr-1">Категория:</span>
<CategoryLabel :id="value.data.category_id"/>
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
import CategoryLabel from "@/components/Form/CategoryLabel.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,30 @@
<template>
<BaseBlock
:title="`Лента товаров - ${value.title || 'Без заголовка'}`"
@onRemove="$emit('onRemove')"
@onShowSettings="$emit('onShowSettings')"
>
<div class="tw:mt-3 tw:text-sm tw:dark:text-slate-300 tw:space-y-1">
<div>
<span class="tw:font-bold tw:dark:text-slate-200">Описание:</span>
{{ value.description }}
</div>
<div>
<span class="tw:font-bold tw:dark:text-slate-200">Максимальное кол-во страниц:</span>
{{ value.data.max_page_count }}
</div>
</div>
</BaseBlock>
</template>
<script setup>
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,46 @@
<template>
<BaseBlock
:title="`Слайдер - ${value.title || 'Без заголовка'}`"
@onRemove="$emit('onRemove')"
@onShowSettings="$emit('onShowSettings')"
>
<div class="tw:mt-3 tw:text-sm tw:dark:text-slate-300 tw:space-y-1">
<div><span class="tw:font-bold tw:dark:text-slate-200">Статус:</span>
{{ value.is_enabled ? 'Включен' : 'Выключен' }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Эффект:</span>
{{ sliderEffectOptions[value.data.effect] || value.data.effect }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Авто:</span>
{{ value.data.autoplay ? 'Включен' : 'Выключен' }}
</div>
<div><span class="tw:font-bold tw:dark:text-slate-200">Цель Яндекс.Метрики:</span>
{{ value.goal_name || 'Не задана' }}
</div>
</div>
<div class="tw:mt-6 tw:flex tw:flex-wrap tw:gap-4">
<img
v-if="value.data.slides && value.data.slides.length > 0"
v-for="slide in value.data.slides"
:alt="slide.title"
class="tw:w-24 tw:h-24 tw:object-cover tw:rounded-md tw:border-2 tw:border-slate-200 dark:tw:border-slate-600"
:src="getThumb(slide.image)"
/>
</div>
</BaseBlock>
</template>
<script setup>
import {getThumb} from "@/utils/helpers.js";
import {sliderEffectOptions} from "@/utils/constants..js";
import BaseBlock from "@/components/MainPageConfigurator/Blocks/BaseBlock.vue";
const emit = defineEmits(['onRemove', 'onShowSettings']);
const props = defineProps({
value: {
type: Object,
required: true,
}
});
</script>

View File

@@ -0,0 +1,31 @@
<template>
<Dropdown
v-model="model"
:options="aspectRatioOptions"
optionLabel="label"
optionValue="value"
placeholder="Выберите соотношение"
class="tw:w-full md:tw:w-96"
>
<template #option="slotProps">
<div class="tw:flex tw:flex-col">
<span class="tw:font-medium">{{ slotProps.option.label }}</span>
<span class="tw:text-xs tw:text-gray-500 tw:whitespace-normal">{{ slotProps.option.description }}</span>
</div>
</template>
</Dropdown>
</template>
<script setup>
import { Dropdown } from "primevue";
const model = defineModel();
const aspectRatioOptions = [
{ label: '1:1', value: '1:1', description: 'Универсально, аксессуары, мелкие товары, удобно для всех товаров — идеально для сетки.' },
{ label: '4:5', value: '4:5', description: 'Одежда, обувь, вертикальные товары, где нужно показать высоту (футболки, платья).' },
{ label: '3:4', value: '3:4', description: 'Одежда, обувь, вертикальные товары, где нужно показать высоту (футболки, платья).' },
{ label: '2:3', value: '2:3', description: 'Цветы, высокие предметы (бутылки, букеты, декоративные элементы).' },
];
</script>

View File

@@ -0,0 +1,130 @@
<template>
<Tabs value="0">
<TabList>
<Tab value="0">Настройки блока</Tab>
<Tab value="1">Основные настройки</Tab>
<slot name="tabs"></slot>
</TabList>
<TabPanels>
<TabPanel value="0">
<div class="tw:space-y-6">
<!-- Статус -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Статус
</label>
<ToggleSwitch v-model="model.is_enabled"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать этот блок
</small>
</div>
<!-- Заголовок блока -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Заголовок блока
</label>
<InputText
v-model="model.title"
placeholder="заголовок блока"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Текст, который будет выводиться в качестве заголовка блока на главной странице. Оставьте
пустым, если заголовок не требуется.
</small>
</div>
<!-- Описание блока -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Описание блока
</label>
<InputText
v-model="model.description"
placeholder="Описание блока"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Описание выводится под заголовком блока уменьшенным шрифтом. Оставьте пустым, если
описание не требуется.
</small>
</div>
<!-- Цель Яндекс.Метрики -->
<div>
<div class="tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Цель Яндекс.Метрики
</label>
<InputText
v-model="model.goal_name"
placeholder="Название цели для Яндекс.Метрики"
class="tw:w-full"
/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Цель в Яндекс.Метрике для отслеживания кликов по блоку.
Оставьте пустым, если не нужно отслеживать клики по этому блоку.
</small>
</div>
</div>
</TabPanel>
<TabPanel value="1">
<slot></slot>
</TabPanel>
<slot name="panels"></slot>
</TabPanels>
</Tabs>
<Divider/>
<div class="tw:flex tw:items-center tw:justify-between tw:gap-4">
<div class="tw:flex tw:gap-2">
<Button
label="Применить"
icon="fa fa-check"
v-tooltip.top="isChanged ? 'Применить изменения' : 'Нет изменений для сохранения'"
:disabled="isChanged === false"
@click="onApply"
/>
<Button label="Отмена" severity="secondary" @click="$emit('cancel')"/>
</div>
<div v-if="isChanged" class="tw:flex tw:items-center tw:gap-2 tw:text-amber-600">
<i class="fa fa-exclamation-triangle"></i>
<span class="tw:text-sm">Есть несохранённые изменения</span>
</div>
</div>
</template>
<script setup>
import {Button, Divider, InputText, Panel, ToggleSwitch} from 'primevue';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
const model = defineModel();
const emit = defineEmits(['onApply', 'cancel']);
const props = defineProps({
isChanged: {
type: Boolean,
default: false,
}
});
function onApply() {
emit('onApply');
}
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<div class="tw:space-y-6">
<!-- Количество категорий -->
<FormItem label="Количество категорий">
<template #default>
<InputNumber
v-model="draft.data.count"
:min="0"
:max="100"
placeholder="10"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">шт.</span>
</template>
<template #help>
Количество категорий, которое нужно выводить в блоке. Если поставить 0, то будет
выводиться только кнопка "Каталог".
</template>
</FormItem>
</div>
</BaseForm>
</div>
</template>
<script setup>
import {computed, defineExpose, onMounted, ref} from "vue";
import {md5} from "js-md5";
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
import {InputNumber, Panel} from "primevue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => md5(JSON.stringify(model.value)) !== md5(JSON.stringify(draft.value)));
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(model.value));
});
defineExpose({isChanged});
</script>

View File

@@ -0,0 +1,25 @@
<template>
<!-- Расстояние между слайдами -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2 tw:gap-4">
<label v-if="label" class="tw:font-medium tw:text-gray-700 tw:flex-shrink-0">
{{ label }}
</label>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-shrink-0">
<slot/>
</div>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
<slot name="help"/>
</small>
</div>
</template>
<script setup>
const props = defineProps({
label: {
type: String,
default: null,
},
});
</script>

View File

@@ -0,0 +1,254 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<template #default>
<div class="tw:space-y-6">
<Panel header="Основные настройки">
<div class="tw:space-y-6">
<!-- Категория -->
<FormItem label="Категория">
<template #default>
<CategorySelect
v-model="draft.data.category_id"
placeholder="Выберите категорию"
/>
</template>
<template #help>
Категория из которой выводить товары для карусели.
</template>
</FormItem>
<!-- Текст кнопки просмотра категории -->
<FormItem label="Текст на кнопке">
<template #default>
<InputText
v-model="draft.data.all_text"
placeholder="Смотреть всё"
class="tw:w-full"
/>
</template>
<template #help>
Текст для кнопки, которая открывает просмотр товаров у категории
</template>
</FormItem>
</div>
</Panel>
<Panel header="Настройки карусели">
<div class="tw:space-y-6">
<!-- Количество товаров в карусели -->
<FormItem label="Количество товаров в карусели">
<template #default>
<InputNumber
v-model="slidesPerView"
:min="2"
:max="5"
:step="0.5"
placeholder="2.5"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">шт.</span>
</template>
<template #help>
Введите количество товаров, которые должны отображаться одновременно в карусели (от 2 до 5).
Можно использовать дробные значения, чтобы часть следующего товара была видна.
</template>
</FormItem>
<!-- Расстояние между товарами -->
<FormItem label="Расстояние между товарами">
<template #default>
<InputNumber
v-model="spaceBetween"
:min="0"
:max="100"
placeholder="20"
:step="5"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">px</span>
</template>
<template #help>
Задайте промежуток между товарами в карусели в пикселях, чтобы слайды не сливались и выглядели
аккуратно.
</template>
</FormItem>
<!-- Режим Авто -->
<FormItem label="Автоматическая прокрутка">
<template #default>
<ToggleSwitch
v-model="isAutoplayEnabled"
/>
</template>
<template #help>
Включите автоматическую прокрутку карусели с заданной задержкой между переходами.
</template>
</FormItem>
<!-- Задержка -->
<FormItem v-if="isAutoplayEnabled" label="Задержка">
<template #default>
<InputNumber
v-model="autoplayDelay"
:min="1000"
:max="10000"
placeholder="3000"
:step="1000"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">мс</span>
</template>
<template #help>
Задержка между переходами в миллисекундах. Минимум 1000, максимум 10000.
</template>
</FormItem>
<!-- Свободный режим -->
<FormItem label="Свободный режим">
<template #default>
<ToggleSwitch v-model="freeMode"/>
</template>
<template #help>
Включает «свободный режим» прокрутки слайдов без привязки к конкретным индексам.
Слайды прокручиваются плавно, скорость зависит от инерции свайпа.
</template>
</FormItem>
</div>
</Panel>
</div>
</template>
</BaseForm>
</div>
</template>
<script setup>
import {computed, defineExpose, onMounted, ref} from "vue";
import {md5} from "js-md5";
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
import CategorySelect from "@/components/Form/CategorySelect.vue";
import {Fieldset, InputNumber, InputText, Panel, ToggleSwitch} from "primevue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => {
return md5(JSON.stringify(model.value)) !== md5(JSON.stringify(draft.value));
});
// Инициализация carousel, если его нет (только для записи)
function ensureCarousel() {
if (!draft.value.data.carousel) {
draft.value.data.carousel = {};
}
}
// Безопасное чтение значения из carousel
function getCarouselValue(key, defaultValue) {
return draft.value.data.carousel?.[key] ?? defaultValue;
}
// Computed для управления slides_per_view
const slidesPerView = computed({
get() {
return getCarouselValue('slides_per_view', 2.5);
},
set(value) {
ensureCarousel();
draft.value.data.carousel.slides_per_view = value;
}
});
// Computed для управления space_between
const spaceBetween = computed({
get() {
return getCarouselValue('space_between', 20);
},
set(value) {
ensureCarousel();
draft.value.data.carousel.space_between = value;
}
});
// Computed для управления autoplay (включен/выключен)
const isAutoplayEnabled = computed({
get() {
const autoplay = draft.value.data.carousel?.autoplay;
return autoplay !== false && autoplay !== null && autoplay !== undefined;
},
set(value) {
ensureCarousel();
if (value) {
// Если включаем, создаем объект с delay (используем текущее значение или 3000 по умолчанию)
const currentDelay = draft.value.data.carousel.autoplay?.delay || 3000;
draft.value.data.carousel.autoplay = { delay: currentDelay };
} else {
// Если выключаем, устанавливаем false
draft.value.data.carousel.autoplay = false;
}
}
});
// Computed для управления delay
const autoplayDelay = computed({
get() {
const autoplay = draft.value.data.carousel?.autoplay;
if (autoplay && typeof autoplay === 'object' && autoplay.delay) {
return autoplay.delay;
}
return 3000; // Значение по умолчанию
},
set(value) {
ensureCarousel();
// Убеждаемся, что autoplay - это объект
if (!draft.value.data.carousel.autoplay || draft.value.data.carousel.autoplay === false) {
draft.value.data.carousel.autoplay = { delay: value };
} else {
draft.value.data.carousel.autoplay.delay = value;
}
}
});
const freeMode = computed({
get() {
const freemode = draft.value.data.carousel?.freemode;
if (freemode && typeof freemode === 'object' && freemode.enabled) {
return freemode.enabled;
}
return false;
},
set(value) {
ensureCarousel();
// Убеждаемся, что autoplay - это объект
if (!draft.value.data.carousel.freemode) {
draft.value.data.carousel.freemode = {};
draft.value.data.carousel.freemode.enabled = value;
} else {
draft.value.data.carousel.freemode.enabled = value;
}
}
});
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(async () => {
draft.value = JSON.parse(JSON.stringify(model.value));
// Не создаем carousel здесь, чтобы не изменять draft при инициализации
// carousel будет создан только при реальных изменениях пользователем
});
defineExpose({isChanged});
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<div class="tw:space-y-6">
<!-- Максимальное количество страниц -->
<FormItem label="Максимальное количество страниц">
<template #default>
<InputNumber
v-model="draft.data.max_page_count"
:min="1"
:max="100"
placeholder="10"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">страниц</span>
</template>
<template #help>
Укажите, сколько страниц товаров можно подгружать при бесконечной прокрутки.
После достижения этого лимита подгрузка остановится.
Ограничение страниц снижает нагрузку на сервер.
</template>
</FormItem>
</div>
</BaseForm>
</div>
</template>
<script setup>
import {computed, defineExpose, onMounted, ref} from "vue";
import {md5} from "js-md5";
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
import {InputNumber} from "primevue";
import FormItem from "@/components/MainPageConfigurator/Forms/FormItem.vue";
const draft = ref(null);
const model = defineModel();
const emit = defineEmits(['cancel']);
const isChanged = computed(() => {
const normalize = (obj) => {
return JSON.stringify(obj, (key, value) => {
if (['max_page_count'].includes(key)) {
return value !== null && value !== undefined && value !== '' ? parseInt(value) : value;
}
return value;
});
};
return md5(normalize(model.value)) !== md5(normalize(draft.value));
});
function onApply() {
model.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(model.value));
if (draft.value.data) {
if (draft.value.data.max_page_count) draft.value.data.max_page_count = parseInt(draft.value.data.max_page_count);
}
});
defineExpose({isChanged});
</script>

View File

@@ -0,0 +1,271 @@
<template>
<div v-if="draft">
<BaseForm
v-model="draft"
:isChanged="isChanged"
@onApply="onApply"
@cancel="$emit('cancel')"
>
<!-- Основные настройки -->
<Panel header="Основные настройки" class="tw:mb-4">
<div class="tw:space-y-6">
<!-- Эффект смены слайдов -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Эффект смены слайдов
</label>
<Dropdown
v-model="draft.data.effect"
:options="effectOptionsList"
optionLabel="label"
optionValue="value"
placeholder="Выберите эффект"
class="tw:w-64"
/>
</div>
</div>
<!-- Пагинация -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Пагинация
</label>
<ToggleSwitch v-model="draft.data.pagination"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать точки под слайдером для индикации текущего слайда.
</small>
</div>
<!-- Полоса прокрутки -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Полоса прокрутки
</label>
<ToggleSwitch v-model="draft.data.scrollbar"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Показывать полосу прокрутки под слайдером для навигации между слайдами.
</small>
</div>
<!-- Расстояние между слайдами -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2 tw:gap-4">
<label class="tw:font-medium tw:text-gray-700 tw:flex-shrink-0">
Расстояние между слайдами
</label>
<div class="tw:flex tw:items-center tw:gap-2 tw:flex-shrink-0">
<InputNumber
v-model="draft.data.space_between"
:min="0"
:max="100"
placeholder="30"
:showButtons="true"
/>
<span class="tw:text-gray-600 tw:whitespace-nowrap">px</span>
</div>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Расстояние между слайдами в пикселях. По умолчанию - 30.
</small>
</div>
<!-- Свободный режим -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Свободный режим
</label>
<ToggleSwitch v-model="draft.data.free_mode"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Позволяет свободно прокручивать слайды без привязки к конкретным позициям.
</small>
</div>
<!-- Бесконечная прокрутка -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Бесконечная прокрутка
</label>
<ToggleSwitch v-model="draft.data.loop"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Включите этот режим, чтобы после последнего слайда слайдер продолжал прокрутку с
первого, создавая бесконечный цикл.
</small>
</div>
<!-- Автоматическая прокрутка -->
<div>
<div class="tw:flex tw:items-center tw:justify-between tw:mb-2">
<label class="tw:font-medium tw:text-gray-700">
Автоматическая прокрутка
</label>
<ToggleSwitch v-model="draft.data.autoplay"/>
</div>
<small class="tw:block tw:text-sm tw:text-gray-500">
Слайдер будет автоматически листать изображения каждые 3 секунды.
</small>
</div>
</div>
</Panel>
<!-- Слайды -->
<Panel header="Слайды">
<template #icons>
<Button
severity="success"
text
rounded
aria-label="Добавить слайд"
@click="addSlide"
>
<i class="fa fa-plus"></i> Добавить новый слайд
</Button>
</template>
<div v-if="draft.data.slides.length === 0" class="tw:text-center tw:py-8 tw:text-gray-500">
<i class="fa fa-image fa-3x tw:mb-4"></i>
<p class="tw:font-bold">Слайды не добавлены</p>
<Button
label="Добавить первый слайд"
severity="success"
outlined
class="tw:mt-4"
@click="addSlide"
>
<i class="fa fa-plus"></i>
</Button>
</div>
<div v-else class="tw:space-y-4">
<div
v-for="(slide, index) in draft.data.slides"
:key="index"
class="tw:bg-white tw:rounded-lg tw:border tw:border-gray-200 tw:p-4 tw:shadow-sm tw:relative"
>
<div class="tw:absolute tw:top-2 tw:right-2">
<Button
severity="danger"
text
rounded
aria-label="Удалить слайд"
@click="removeSlide($event, index)"
>
<i class="fa fa-trash tw:text-lg"></i>
</Button>
</div>
<div class="tw:flex">
<!-- Изображение -->
<div class="tw:mr-5">
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Изображение
</label>
<OcImagePicker v-model="slide.image"/>
</div>
<!-- Поля -->
<div class="tw:space-y-4">
<div>
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Заголовок слайда
</label>
<InputText
v-model="slide.title"
placeholder="Введите заголовок слайда"
class="tw:w-full"
/>
<small class="tw:block tw:text-sm tw:text-gray-500">
Заголовок слайда будет отправляться в цели Яндекс.Метрики
</small>
</div>
<div>
<label class="tw:block tw:mb-2 tw:font-medium tw:text-gray-700">
Ссылка
</label>
<LinkSelector v-model="slide.link"/>
</div>
</div>
</div>
</div>
</div>
</Panel>
</BaseForm>
</div>
</template>
<script setup>
import {computed, defineExpose, onMounted, ref} from "vue";
import OcImagePicker from "@/components/OcImagePicker.vue";
import LinkSelector from "@/components/Slider/LinkSelector.vue";
import {Button, Dropdown, InputNumber, InputText, Panel, ToggleSwitch, useConfirm} from 'primevue';
import {sliderEffectOptions} from "@/utils/constants..js";
import {md5} from "js-md5";
import BaseForm from "@/components/MainPageConfigurator/Forms/BaseForm.vue";
const confirm = useConfirm();
const draft = ref(null);
const slider = defineModel();
const isChanged = computed(() => md5(JSON.stringify(slider.value)) !== md5(JSON.stringify(draft.value)));
const effectOptionsList = computed(() => {
return Object.entries(sliderEffectOptions).map(([value, label]) => ({
value,
label,
}));
});
function removeSlide(event, index) {
confirm.require({
group: 'popup',
target: event.currentTarget,
message: 'Удалить слайд?',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Удалить',
severity: 'danger'
},
accept: () => draft.value.data.slides.splice(index, 1),
});
}
function addSlide() {
draft.value.data.slides.push({
title: '',
link: {
type: 'none',
value: null,
},
image: '',
});
}
function onApply() {
slider.value = JSON.parse(JSON.stringify(draft.value));
}
onMounted(() => {
draft.value = JSON.parse(JSON.stringify(slider.value));
});
defineExpose({isChanged});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="tw:flex tw:gap-4">
<section class="tw:w-1/3 tw:p-4 tw:bg-slate-100 tw:rounded-lg">
<header class="tw:font-semibold tw:text-lg tw:uppercase">Доступные блоки</header>
<div class="tw:mb-6">Перетяните блок, чтобы добавить на главную страницу</div>
<draggable
v-model="availableBlocks"
:group="{ name: 'blocks', pull: 'clone', put: false }"
:clone="cloneBlock"
item-key="type"
class="tw:space-y-2"
chosenClass="tw:scale-98"
>
<template #item="{ element, index }">
<Card class="tw:cursor-move">
<template #title>
<i class="fa fa-arrows"></i>
{{ element.title }}
</template>
<template #content>
<p class="m-0">
{{ element.description }}
</p>
</template>
</Card>
</template>
</draggable>
</section>
<section class="tw:w-full tw:rounded-xl tw:p-4 tw:bg-slate-100 tw:min-h-[400px] tw:relative">
<header class="tw:font-semibold tw:text-lg tw:uppercase">Блоки на главной странице</header>
<div class="tw:mb-6">Эти блоки будут отображены на главной странице в том же порядке. Перетяните блок, если хотите изменить порядок.</div>
<draggable
v-model="settings.items.mainpage_blocks"
:group="{ name: 'blocks', put: true }"
item-key="type"
class="tw:w-full tw:h-full tw:min-h-[400px] tw:space-y-2"
@change="onChange"
>
<template #item="{ element, index }">
<template v-if="blockToComponentMap[element.type]">
<div class="tw:bg-white tw:rounded-lg tw:p-6 tw:border tw:border-slate-200">
<component
:is="blockToComponentMap[element.type]"
:value="element"
@onRemove="removeBlock(index)"
@onShowSettings="showDrawer(index)"
/>
</div>
</template>
<div v-else>неподдерживаемый блок</div>
</template>
</draggable>
<div
v-if="!hasBlocks"
class="tw:absolute tw:inset-0 tw:flex tw:flex-col tw:items-center tw:justify-center tw:text-center tw:py-12 tw:px-4 tw:pointer-events-none"
>
<div class="tw:mb-6 tw:text-6xl tw:text-gray-400">
<i class="fa fa-inbox"></i>
</div>
<h3 class="tw:text-xl tw:font-semibold tw:text-gray-700 tw:mb-2">
Нет блоков на главной странице
</h3>
<p class="tw:text-gray-500 tw:max-w-md tw:mb-4">
Перетащите блок из левой панели, чтобы добавить его на главную страницу
</p>
</div>
</section>
<Drawer
:visible="isDrawerSettingsVisible"
@update:visible="closeDrawer"
:header="drawerTitle"
position="right"
:baseZIndex="1000"
class="tw:!w-full tw:md:!w-80 tw:lg:!w-[50rem]"
>
<template v-if="currentBlock && blockToFormMap[currentBlock.type]">
<component
:is="blockToFormMap[currentBlock.type]"
ref="currentBlockForm"
@cancel="closeDrawer"
:modelValue="settings.items.mainpage_blocks[drawerBlockIndex]"
@update:modelValue="updateBlockData"
/>
</template>
<div v-else>Unsupported block type</div>
</Drawer>
</div>
</template>
<script setup>
import draggable from 'vuedraggable';
import {Card, Drawer, useConfirm} from 'primevue';
import {computed, nextTick, ref} from "vue";
import {useSettingsStore} from "@/stores/settings.js";
import {
blocks,
blockToComponentMap,
blockToFormMap
} from "@/components/MainPageConfigurator/availableBlocks.js";
const settings = useSettingsStore();
const confirm = useConfirm();
const availableBlocks = ref(blocks);
const isDrawerSettingsVisible = ref(null);
const drawerBlockIndex = ref(null);
const currentBlockForm = ref(null);
const currentBlock = computed(() => {
if (drawerBlockIndex.value >= 0) {
return settings.items.mainpage_blocks[drawerBlockIndex.value];
}
return null;
});
const drawerTitle = computed(() => {
if (currentBlock.value) {
return `Редактирование ${currentBlock?.value?.type} - ${currentBlock?.value?.title || 'Без заголовка'}`;
}
return '';
});
const hasBlocks = computed(() => {
return settings.items.mainpage_blocks && settings.items.mainpage_blocks.length > 0;
});
function removeBlock(index) {
settings.items.mainpage_blocks.splice(index, 1);
}
function cloneBlock(block) {
const newBlock = JSON.parse(JSON.stringify(block));
newBlock.title = '';
newBlock.description = '';
return newBlock;
}
function showDrawer(blockIndex) {
if (currentBlock.value !== null) {
drawerBlockIndex.value = blockIndex;
isDrawerSettingsVisible.value = true;
}
}
function closeDrawer() {
// Проверяем, есть ли несохраненные изменения
if (currentBlockForm.value?.isChanged === true) {
confirm.require({
message: 'У вас есть несохраненные изменения. Вы уверены, что хотите закрыть форму?',
header: 'Подтверждение закрытия',
icon: 'pi pi-exclamation-triangle',
rejectProps: {
label: 'Отмена',
severity: 'secondary',
outlined: true
},
acceptProps: {
label: 'Закрыть',
severity: 'danger'
},
accept: () => {
drawerBlockIndex.value = null;
isDrawerSettingsVisible.value = false;
}
});
} else {
drawerBlockIndex.value = null;
isDrawerSettingsVisible.value = false;
}
}
function onChange(update) {
if (update.added && update.added?.newIndex >= 0) {
showDrawer(update.added.newIndex);
}
}
function updateBlockData(newBlockData) {
if (drawerBlockIndex.value !== null && drawerBlockIndex.value >= 0) {
settings.items.mainpage_blocks.splice(drawerBlockIndex.value, 1, newBlockData);
nextTick(() => closeDrawer());
}
}
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,82 @@
import SliderBlock from "@/components/MainPageConfigurator/Blocks/SliderBlock.vue";
import CategoriesTopBlock from "@/components/MainPageConfigurator/Blocks/CategoriesTopBlock.vue";
import SliderForm from "@/components/MainPageConfigurator/Forms/SliderForm.vue";
import CategoriesTopForm from "@/components/MainPageConfigurator/Forms/CategoriesTopForm.vue";
import ProductsFeedBlock from "@/components/MainPageConfigurator/Blocks/ProductsFeedBlock.vue";
import ProductsFeedForm from "@/components/MainPageConfigurator/Forms/ProductsFeedForm.vue";
import ProductsCarouselBlock
from "@/components/MainPageConfigurator/Blocks/ProductsCarouselBlock.vue";
import ProductsCarouselForm from "@/components/MainPageConfigurator/Forms/ProductsCarouselForm.vue";
export const blockToComponentMap = {
slider: SliderBlock,
categories_top: CategoriesTopBlock,
products_feed: ProductsFeedBlock,
products_carousel: ProductsCarouselBlock,
};
export const blockToFormMap = {
slider: SliderForm,
categories_top: CategoriesTopForm,
products_feed: ProductsFeedForm,
products_carousel: ProductsCarouselForm,
};
export const blocks = [
{
type: 'slider',
title: 'Слайдер',
description: 'Изображения объединённые в слайдер.',
is_enabled: true,
goal_name: '',
data: {
effect: "slide",
pagination: true,
scrollbar: false,
free_mode: false,
space_between: 5,
autoplay: false,
loop: false,
slides: [],
},
},
{
type: 'categories_top',
title: 'Топ категорий',
description: 'Виджет с кнопками популярных категорий и кнопкой «Каталог» для всех категорий.',
is_enabled: true,
goal_name: '',
data: {
count: 10,
},
},
{
type: 'products_feed',
title: 'Лента товаров',
description: 'Отображает товары в виде прокручиваемой ленты с возможностью подгрузки новых элементов по мере скролла.',
is_enabled: true,
goal_name: '',
data: {
max_page_count: 10,
},
},
{
type: 'products_carousel',
title: 'Карусель товаров',
description: 'Отображает товары в одну строку в виде прокручиваемой карусели.',
is_enabled: true,
goal_name: '',
data: {
category_id: null,
all_text: null,
carousel: {
slides_per_view: 2.5,
space_between: 10,
autoplay: false,
freemode: {
enabled: false,
},
},
},
},
];

View File

@@ -0,0 +1,54 @@
<template>
<div class="oc-image">
<div v-if="isLoaded === false" class="loader">
<i class="fa fa-spinner fa-spin"></i>
</div>
<a v-show="isLoaded" href="#" data-toggle="image" class="img-thumbnail" :id="`thumb-image-${id}`">
<img
:src="thumb"
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}`">
</div>
</template>
<script setup>
import {computed, onMounted, ref, useId} from "vue";
import {getThumb} from "@/utils/helpers.js";
const id = useId();
const model = defineModel();
const emit = defineEmits(['update:modelValue']);
const inputRef = ref(null);
const isLoaded = ref(false);
const thumb = computed(() => getThumb(model.value));
onMounted(() => {
const input = inputRef.value;
const observer = new MutationObserver(() => {
const val = input.value;
console.log("Updated value: ", val);
if (val !== model.value) {
emit('update:modelValue', val);
}
});
observer.observe(input, {attributes: true, attributeFilter: ['value']});
});
</script>
<style scoped>
.loader {
width: 100px;
height: 100px;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div class="tw:space-y-2">
<textarea
ref="textareaRef"
class="form-control"
:placeholder="placeholder"
></textarea>
</div>
</template>
<script setup>
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
const props = defineProps({
placeholder: {
type: String,
default: "",
},
height: {
type: Number,
default: 240,
},
});
const model = defineModel({
type: String,
default: "",
});
const textareaRef = ref(null);
const summernoteInstance = ref(null);
const getJQuery = () => window.$ || window.jQuery;
const normalizeTelegramHtml = (html = "") => {
const withoutEmptyParagraphs = html.replace(/<p><br><\/p>/gi, "<br>");
const withoutParagraphs = withoutEmptyParagraphs
.replace(/<p>/gi, "")
.replace(/<\/p>/gi, "<br>");
return withoutParagraphs.replace(/(?:<br>\s*)+$/i, "").trim();
};
const makeSpoilerButton = ($) => (context) => {
const ui = $.summernote.ui;
return ui
.button({
contents: '<i class="fa fa-eye-slash"></i>',
tooltip: "Спойлер (Telegram)",
click() {
const selectedText = context.invoke("editor.getSelectedText") || "";
const content = selectedText || "spoiler";
context.invoke(
"editor.pasteHTML",
`<span class="tg-spoiler">${content}</span>`
);
},
})
.render();
};
onMounted(() => {
const $ = getJQuery();
if (!$ || !textareaRef.value) {
console.warn("[RichTextEditor] jQuery или textarea недоступны");
return;
}
const $el = $(textareaRef.value);
$el.summernote({
height: props.height,
placeholder: props.placeholder,
shortcuts: false,
dialogsInBody: true,
disableResizeEditor: true,
buttons: {
spoiler: makeSpoilerButton($),
},
toolbar: [
["font", ["bold", "underline", "italic", "strikethrough", "clear"]],
["para", ["ul", "ol", "paragraph"]],
["insert", ["link", "spoiler"]],
["view", ["fullscreen", "codeview", "help"]],
],
callbacks: {
onChange(contents) {
const normalized = normalizeTelegramHtml(contents ?? "");
if (normalized !== contents) {
$el.summernote("code", normalized);
return;
}
model.value = normalized;
},
onKeydown(e) {
if (e.keyCode === 13) {
e.preventDefault();
$el.summernote("pasteHTML", "<br>");
}
},
},
});
if (model.value) {
$el.summernote("code", normalizeTelegramHtml(model.value));
}
summernoteInstance.value = $el;
});
watch(
model,
(value) => {
const instance = summernoteInstance.value;
if (!instance) {
return;
}
const normalized = normalizeTelegramHtml(value || "");
const current = normalizeTelegramHtml(instance.summernote("code"));
if (current !== normalized) {
instance.summernote("code", normalized);
}
}
);
onBeforeUnmount(() => {
if (summernoteInstance.value) {
summernoteInstance.value.summernote("destroy");
summernoteInstance.value = null;
}
});
</script>

View File

@@ -0,0 +1,108 @@
<template>
<div class="tw:border tw:border-gray-300 tw:rounded-md tw:bg-white tw:overflow-hidden">
<div class="tw:flex tw:flex-col">
<div
v-for="job in scheduledJobs"
:key="job.id"
class="tw:flex tw:items-center tw:gap-4 tw:py-3 tw:px-3 tw:border-b tw:border-gray-200 tw:last:border-b-0"
>
<ToggleSwitch
:model-value="Boolean(job.is_enabled)"
@update:model-value="onJobToggle(job, $event)"
/>
<div class="tw:min-w-0 tw:flex-1 tw:flex tw:flex-col tw:gap-0.5">
<span class="tw:text-[inherit] tw:font-bold">{{ hasJobMeta(job.name) ? jobMeta(job.name).friendlyName : job.name }}</span>
<span v-if="hasJobMeta(job.name) && jobMeta(job.name).description" class="tw:text-sm tw:text-gray-500">
{{ jobMeta(job.name).description }}
</span>
</div>
<span
v-if="job.failed_reason"
class="tw:inline-flex tw:items-center tw:gap-1 tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-red-100 tw:text-red-700 tw:cursor-default"
role="img"
:aria-label="'Ошибка: ' + job.failed_reason"
v-tooltip.top="errorTooltip(job)"
>
<i class="fa fa-exclamation-circle" aria-hidden="true"/>
Ошибка
</span>
<div
v-else
class="tw:flex tw:flex-col tw:gap-0.5 tw:shrink-0 tw:items-end"
>
<span
class="tw:inline-flex tw:items-center tw:shrink-0 tw:px-2 tw:py-1 tw:rounded tw:text-sm tw:bg-green-100 tw:text-green-800 tw:cursor-default"
v-tooltip.top="'Дата последнего успешного запуска'"
>
{{ formatLastRun(job.last_success_at) }}
</span>
<span
v-if="formatDuration(job.last_duration_seconds)"
class="tw:text-xs tw:text-gray-500"
>
Время выполнения: {{ formatDuration(job.last_duration_seconds) }}
</span>
</div>
<div class="tw:shrink-0 tw:w-[14rem]">
<CronExpressionSelect
:model-value="job.cron_expression"
@update:model-value="job.cron_expression = $event"
/>
</div>
</div>
<div v-if="!scheduledJobs.length" class="tw:text-gray-500 tw:py-4 tw:px-3">
Нет запланированных задач
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useSettingsStore } from '@/stores/settings.js';
import ToggleSwitch from 'primevue/toggleswitch';
import CronExpressionSelect from '@/components/CronExpressionSelect.vue';
const settings = useSettingsStore();
/** Человекочитаемое имя и описание задач (ключ — job.name с бэкенда) */
const JOB_META = {
acmeshop_pulse_send_events: {
friendlyName: 'Отправка данных в AcmeShop Pulse',
description: 'Отправка данных телеметрии о действиях в AcmeShop. Требуется для сбора метрик по рассылкам и кампаниям, сделанных через сервис AcmeShop Pulse',
},
};
function hasJobMeta(jobName) {
return jobName in JOB_META;
}
function jobMeta(jobName) {
return JOB_META[jobName];
}
const scheduledJobs = computed(() => settings.items.scheduled_jobs ?? []);
function formatLastRun(value) {
if (!value) return '—';
const d = typeof value === 'string' ? new Date(value.replace(' ', 'T')) : new Date(value);
return Number.isNaN(d.getTime()) ? value : d.toLocaleString('ru-RU');
}
function formatDuration(seconds) {
if (seconds == null || seconds === '' || Number(seconds) <= 0) return '';
const n = Number(seconds);
if (Number.isNaN(n)) return '';
if (n < 1) return 'менее 1с';
return `${n % 1 ? n.toFixed(1) : Math.round(n)}с`;
}
function errorTooltip(job) {
const dateStr = formatLastRun(job.failed_at);
return `${dateStr}\n${job.failed_reason ?? ''}`.trim();
}
function onJobToggle(job, isEnabled) {
job.is_enabled = isEnabled ? 1 : 0;
}
</script>

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

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

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

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

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

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://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>

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

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

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

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

View File

@@ -0,0 +1,67 @@
<template>
<div class="form-group">
<div class="col-sm-2 tw:flex tw:flex-col tw:gap-1">
<label class="control-label" for="module_tgshop_status">
{{ label }}
</label>
<a
v-if="docHref"
:href="docHref"
target="_blank"
rel="noopener noreferrer"
class="tw:inline-flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-500 hover:tw:text-gray-700 tw:underline tw:decoration-dotted tw:decoration-1 tw:underline-offset-2 tw:w-fit tw:self-end"
>
<i class="fa fa-external-link tw:text-xs" aria-hidden="true"></i>
<span>Документация</span>
</a>
</div>
<div class="col-sm-10">
<slot name="default"></slot>
<div class="help-block">
<slot name="help"></slot>
</div>
<div v-if="hasExpandable">
<Button
:label="expandableLabel"
severity="info"
link
size="small"
@click="expanded = !expanded"
/>
<div v-show="expanded" class="tw:mt-2 tw:space-y-2">
<slot name="expandable"></slot>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, useSlots, computed } from 'vue';
import Button from 'primevue/button';
const props = defineProps({
label: {
type: String,
default: '',
},
/** Ссылка на документацию: отображается под label, открывается в новой вкладке */
docHref: {
type: String,
default: '',
},
/** Подпись кнопки раскрытия блока #expandable (по умолчанию «Подробнее») */
expandableLabel: {
type: String,
default: 'Подробнее',
},
});
const slots = useSlots();
const hasExpandable = computed(() => !!slots.expandable);
const expanded = ref(false);
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div>
<input
type="search"
name="category"
:value="`${category?.name || ''}`"
placeholder="Начните вводить название категории..."
class="form-control"
ref="categoryRef"
/>
</div>
</template>
<script setup>
import {onMounted, onUnmounted, ref} from "vue";
const category = defineModel();
const categoryRef = ref(null);
onMounted(() => {
const input = categoryRef.value;
if (!input) {
return;
}
$(input).autocomplete({
'source': function (request, response) {
if ($(input).val().length === 0) {
$(input).val(null);
}
$.ajax({
url: `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: item['category_id']
}
}));
}
});
},
'select': function (item) {
category.value = {
category_id: Number(item['value']),
name: item['label'],
};
}
});
});
onUnmounted(() => {
const input = categoryRef.value;
if (!input) {
return;
}
$(input).autocomplete('destroy');
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,59 @@
<template>
<div>
<div>
<select v-model="link.type" class="form-control link-type-select" @change="link.value = null">
<option value="none">Нет ссылки</option>
<option value="category">Ссылка на категорию</option>
<option value="product">Ссылка на товар</option>
<option value="url">Внешняя ссылка</option>
</select>
</div>
<div v-if="link.type === 'url'" class="mt-10">
<input
:value="link.value?.url"
@input="setLink($event.target.value)"
type="text"
placeholder="https://example.com"
class="form-control"
/>
</div>
<div v-else-if="link.type === 'category'" class="mt-10">
<CategorySelect v-model="link.value"/>
</div>
<div v-else-if="link.type === 'product'" class="mt-10">
<ProductSelect v-model="link.value"/>
</div>
<div v-else-if="link.type === 'none'"></div>
<div v-else class="alert alert-danger">Не поддерживается: {{ link.type }}</div>
</div>
</template>
<script setup>
import CategorySelect from "@/components/Slider/CategorySelect.vue";
import ProductSelect from "@/components/Slider/ProductSelect.vue";
const link = defineModel();
function setLink(value) {
if (link.value?.value) {
link.value.value.url = value;
} else {
link.value.value = { url: value };
}
}
</script>
<style scoped>
.link-type-select {
cursor: pointer;
}
.mt-10 {
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<div>
<input
type="search"
:value="`${model?.name || ''}`"
placeholder="Начните вводить название товара..."
class="form-control"
ref="inputRef"
/>
</div>
</template>
<script setup>
import {onMounted, onUnmounted, ref} from "vue";
const model = defineModel();
const inputRef = ref(null);
onMounted(() => {
const input = inputRef.value;
if (!input) {
return;
}
$(input).autocomplete({
'source': function (request, response) {
if ($(input).val().length === 0) {
$(input).val(null);
}
$.ajax({
url: `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: item['product_id']
}
}));
}
});
},
'select': function (item) {
model.value = {
product_id: Number(item['value']),
name: item['label'],
};
}
});
});
onUnmounted(() => {
const input = inputRef.value;
if (!input) {
return;
}
$(input).autocomplete('destroy');
});
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,15 @@
<template>
<ToggleSwitch v-model="model" />
</template>
<script setup>
import ToggleSwitch from 'primevue/toggleswitch';
const model = defineModel({
default: false,
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,229 @@
<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
v-tooltip.top="'Общее количество заказов, сделанное через AcmeShop за всё время.'"
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
v-tooltip.top="'Итоговая сумма заказов, сделанных через AcmeShop за всё время.'"
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">
{{ rub(stats.items.orders_total_amount ?? 0) }}
</div>
</div>
<div>
<span
v-tooltip.top="'Общее количество уникальных Telegram-посетителей, взаимодействовавших с магазином за всё время включая тех, кто просто заходил посмотреть, без оформления заказа.'"
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">
<RouterLink to="/customers">{{ stats.items.customers_count ?? 0 }}</RouterLink>
</div>
</div>
<div>
<span
v-tooltip.top="'Текущий статус магазина'"
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">
<ButtonGroup>
<ResetCacheBtn/>
<Button
icon="fa fa-list"
v-tooltip.top="'Журнал событий'"
@click="showLogsDrawer = true"
/>
<Button
icon="fa fa-info-circle"
v-tooltip.top="'Системная информация'"
@click="showSystemInfoDrawer = true"
/>
</ButtonGroup>
<ButtonGroup>
<Button
icon="fa fa-play"
v-tooltip.top="(tgMe?.result?.has_main_web_app !== true) ? 'Вы не привязали Telegram Mini App к боту.' : 'Открыть Telegram магазин'"
as="a"
target="_blank"
:href="`https://t.me/${tgMe?.result?.username}?startapp`"
/>
<Button
icon="fa fa-book"
v-tooltip.top="'Документация по модулю AcmeShop'"
as="a"
target="_blank"
href="https://acme-inc.github.io/docs/"
/>
<Button
icon="fa fa-group"
v-tooltip.top="'Официальная Telegram группа модуля AcmeShop'"
as="a"
target="_blank"
href="https://t.me/ocstore3"
/>
</ButtonGroup>
</div>
</div>
<Drawer
v-model:visible="showLogsDrawer"
header="Журнал событий"
position="right"
:baseZIndex="1000"
class="tw:!w-full tw:md:!w-1/2"
>
<LogsViewer/>
</Drawer>
<Drawer
v-model:visible="showSystemInfoDrawer"
header="Системная информация"
position="right"
:baseZIndex="1000"
class="tw:!w-full tw:md:!w-1/2"
>
<div class="tw:flex tw:flex-col tw:gap-4 tw:h-full">
<div class="tw:flex tw:justify-end">
<Button
label="Скопировать"
icon="fa fa-copy"
@click="copySystemInfo"
:disabled="!systemInfo"
/>
</div>
<Textarea
v-model="systemInfo"
readonly
class="tw:w-full tw:h-full tw:font-mono tw:text-sm"
style="font-family: monospace;"
/>
</div>
</Drawer>
</div>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import {useStatsStore} from "@/stores/stats.js";
import {onMounted, ref, watch} from "vue";
import OcImagePicker from "@/components/OcImagePicker.vue";
import {apiGet} from "@/utils/http.js";
import ResetCacheBtn from "@/components/Form/ResetCacheBtn.vue";
import {Button, ButtonGroup, Drawer} from "primevue";
import Textarea from 'primevue/textarea';
import {rub} from "@/utils/helpers.js";
import LogsViewer from "@/components/LogsViewer.vue";
import {useToast} from "primevue/usetoast";
const settings = useSettingsStore();
const stats = useStatsStore();
const toast = useToast();
const tgMe = ref(null);
const showLogsDrawer = ref(false);
const showSystemInfoDrawer = ref(false);
const systemInfo = ref('');
const fetchSystemInfo = async () => {
try {
const response = await apiGet('getSystemInfo');
if (response.success) {
systemInfo.value = response.data;
} else {
systemInfo.value = 'Ошибка при получении системной информации: ' + (response.error || 'Unknown error');
}
} catch (error) {
systemInfo.value = 'Ошибка при получении системной информации: ' + (error.message || 'Unknown error');
}
};
const copySystemInfo = async () => {
if (!systemInfo.value) {
return;
}
try {
await navigator.clipboard.writeText(systemInfo.value);
toast.add({
severity: 'success',
summary: 'Скопировано',
detail: 'Системная информация скопирована в буфер обмена',
life: 3000
});
} catch (error) {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать информацию',
life: 3000
});
}
};
watch(showSystemInfoDrawer, (newValue) => {
if (newValue && !systemInfo.value) {
fetchSystemInfo();
}
});
onMounted(async () => {
await stats.fetchStats();
const response = await apiGet('tgGetMe');
tgMe.value = response.data;
});
</script>
<style scoped lang="scss">
</style>

View File

@@ -0,0 +1,24 @@
import {ru} from '@formkit/i18n';
import * as allIcons from '@formkit/icons';
import {rootClasses} from './formkit.theme.mjs';
// Собираем все иконки в плоский объект
const icons = {};
Object.values(allIcons).forEach(group => {
if (typeof group === 'object') {
Object.assign(icons, group);
}
});
const config = {
locales: {ru},
locale: 'ru',
icons: {
...icons,
},
config: {
rootClasses,
},
};
export default config;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
import './assets/main.css'
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";
import Tooltip from 'primevue/tooltip';
import ConfirmationService from 'primevue/confirmationservice';
import { plugin, defaultConfig } from '@formkit/vue';
import config from './formkit.config.js'
const MyPreset = definePreset(Aura, {
});
function onReady(fn) {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
onReady(async () => {
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(PrimeVue, {
theme: {
preset: MyPreset,
options: {
cssLayer: false, // если используешь Tailwind, отключает layering
},
},
locale: {
startsWith: 'Начинается с',
contains: 'Содержит',
notContains: 'Не содержит',
endsWith: 'Заканчивается на',
equals: 'Равно',
notEquals: 'Не равно',
noFilter: 'Без фильтра',
lt: 'Меньше чем',
lte: 'Меньше или равно',
gt: 'Больше чем',
gte: 'Больше или равно',
dateIs: 'Дата равна',
dateIsNot: 'Дата не равна',
dateBefore: 'Дата до',
dateAfter: 'Дата после',
clear: 'Очистить',
apply: 'Применить',
matchAll: 'Совпадает со всеми',
matchAny: 'Совпадает с любым',
addRule: 'Добавить правило',
removeRule: 'Удалить правило',
accept: 'Да',
reject: 'Нет',
choose: 'Выбрать',
upload: 'Загрузить',
cancel: 'Отмена',
dayNames: ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'],
dayNamesShort: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
dayNamesMin: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
monthNames: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'],
monthNamesShort: ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн', 'Июл', 'Авг', 'Сен', 'Окт', 'Ноя', 'Дек'],
today: 'Сегодня',
weekHeader: 'Неделя',
firstDayOfWeek: 1,
dateFormat: 'dd.mm.yy',
weak: 'Слабый',
medium: 'Средний',
strong: 'Сильный',
passwordPrompt: 'Введите пароль',
emptyMessage: 'Нет доступных записей',
emptyFilterMessage: 'Нет доступных записей'
}
});
app.use(ToastService);
app.directive('tooltip', Tooltip);
app.use(ConfirmationService);
app.use(plugin, defaultConfig(config));
app.mount('#app');
await useSettingsStore().fetchSettings();
});

View File

@@ -0,0 +1,31 @@
import {createMemoryHistory, createRouter} from 'vue-router';
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";
import MainPageView from "@/views/MainPageView.vue";
import FormBuilderView from "@/views/FormBuilderView.vue";
import CustomersView from "@/views/CustomersView.vue";
import AcmeShopPulseView from "@/views/AcmeShopPulseView.vue";
import CronView from "@/views/CronView.vue";
const router = createRouter({
history: createMemoryHistory(),
routes: [
{path: '/', name: 'general', component: GeneralView},
{path: '/customers', name: 'customers', component: CustomersView},
{path: '/formbuilder', name: 'formbuilder', component: FormBuilderView},
{path: '/mainpage', name: 'mainpage', component: MainPageView},
{path: '/metrics', name: 'metrics', component: MetricsView},
{path: '/orders', name: 'orders', component: OrdersView},
{path: '/pulse', name: 'pulse', component: AcmeShopPulseView},
{path: '/store', name: 'store', component: StoreView},
{path: '/telegram', name: 'telegram', component: TelegramView},
{path: '/texts', name: 'texts', component: TextsView},
{path: '/cron', name: 'cron', component: CronView},
],
});
export default router;

View File

@@ -0,0 +1,19 @@
import {defineStore} from "pinia";
import {apiGet} from "@/utils/http.js";
export const useAutocompleteStore = defineStore('autocomplete', {
state: () => ({
categories: null,
}),
actions: {
async fetchCategories() {
if (this.categories !== null) return;
const response = await apiGet('getAutocompleteCategories');
if (response.success) {
this.categories = response.data;
}
},
}
});

View File

@@ -0,0 +1,12 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,14 @@
import {defineStore} from "pinia";
import {apiPost} from "@/utils/http.js";
export const useFormsStore = defineStore('forms', {
state: () => ({}),
actions: {
async getFormByAlias(alias) {
return await apiPost('getFormByAlias', {
alias,
});
}
},
});

View File

@@ -0,0 +1,21 @@
import {defineStore} from "pinia";
import {apiGet} from "@/utils/http.js";
export const useLogsStore = defineStore('logs', {
state: () => ({
logs: [],
loading: false,
}),
actions: {
async fetchLogsFromServer() {
this.loading = true;
try {
const response = await apiGet('getLogs');
this.logs = response.data || [];
} finally {
this.loading = false;
}
},
},
});

View File

@@ -0,0 +1,175 @@
import {defineStore} from "pinia";
import {apiGet, apiPost} from "@/utils/http.js";
import {toastBus} from "@/utils/toastHelper.js";
import {md5} from "js-md5";
export const useSettingsStore = defineStore('settings', {
state: () => ({
isLoading: false,
error: null,
originalItemsHash: null,
items: {
app: {
app_enabled: true,
app_name: '',
app_icon: null,
theme_light: 'light',
theme_dark: 'dark',
app_debug: false,
privacy_policy_link: null,
image_aspect_ratio: '1:1',
image_crop_algorithm: 'cover',
haptic_enabled: true,
},
telegram: {
mini_app_url: '',
bot_token: '',
chat_id: '',
owner_notification_template: '',
customer_notification_template: '',
},
metrics: {
yandex_metrika_enabled: false,
yandex_metrika_counter: '',
},
store: {
feature_coupons: true,
feature_vouchers: true,
show_category_products_button: true,
product_interaction_mode: 'browser',
manager_username: null,
},
orders: {
order_default_status_id: 1,
},
texts: {
text_no_more_products: '',
text_empty_cart: '',
text_order_created_success: '',
zero_price_text: '',
start_image: '',
start_message: '',
start_button: {
text: '',
},
text_manager_button: '',
},
sliders: {
mainpage_slider: {
is_enabled: false,
effect: "slide",
pagination: true,
scrollbar: false,
free_mode: false,
space_between: 30,
autoplay: false,
loop: false,
slides: [],
},
},
mainpage_blocks: [],
forms: {
checkout: {
alias: '',
friendly_name: '',
is_custom: false,
schema: [],
}
},
pulse: {
api_key: '',
batch_size: 50,
},
cron: {
mode: 'disabled',
api_key: '',
schedule_url: '',
},
scheduled_jobs: [],
},
}),
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}`;
},
hasUnsavedChanges: (state) => {
if (!state.originalItemsHash) return false;
return md5(JSON.stringify(state.items)) !== state.originalItemsHash;
},
},
actions: {
async fetchSettings() {
this.isLoading = true;
this.error = null;
const response = await apiGet('getSettingsForm');
if (response.success) {
this.items = {
...this.items,
...response.data,
};
// Нормализуем типы у запланированных задач (is_enabled — число 0/1), чтобы переключение туда-обратно не меняло хеш
const jobs = this.items.scheduled_jobs;
if (Array.isArray(jobs)) {
this.items.scheduled_jobs = jobs.map((job) => ({
...job,
is_enabled: job.is_enabled === true || job.is_enabled === 1 || job.is_enabled === '1' ? 1 : 0,
}));
}
// Сохраняем хеш исходного состояния после загрузки
this.originalItemsHash = md5(JSON.stringify(this.items));
} 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,
});
// Обновляем хеш исходного состояния после успешного сохранения
this.originalItemsHash = md5(JSON.stringify(this.items));
} 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 {apiPost} from "@/utils/http.js";
export const useStatsStore = defineStore('stats', {
state: () => ({
items: {
orders_count: null,
orders_total_amount: null,
customers_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.customers_count = response.data?.data?.customers_count;
}
},
});

View File

@@ -0,0 +1,7 @@
export const sliderEffectOptions = {
slide: 'Слайд',
flip: 'Переворот',
cards: 'Карточки',
cube: 'Куб',
coverflow: 'Перекрывающиеся слайды',
};

View File

@@ -0,0 +1,17 @@
export function getThumb(imageUrl) {
const url = new URL(`${window.AcmeShop.shop_base_url}/admin/index.php`);
url.searchParams.set('route', 'extension/module/tgshop/handle');
url.searchParams.set('api_action', 'getImage');
url.searchParams.set('path', imageUrl);
url.searchParams.set('size', '100x100');
url.searchParams.set('user_token', window.AcmeShop.user_token);
return url.toString();
}
export function rub(value) {
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0
}).format(value);
}

View File

@@ -0,0 +1,142 @@
import axios from 'axios';
/**
* Получает user_token из глобального объекта AcmeShop
*/
function getUserToken() {
if (typeof window !== 'undefined' && window.AcmeShop?.user_token) {
return window.AcmeShop.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,180 @@
<template>
<div class="tw:space-y-6">
<div class="acmeshop-pulse-info">
<h3>🚀 Расширьте возможности вашего магазина с <strong><a href="https://acmeshop.pro/" target="_blank">AcmeShop Pulse</a>!</strong></h3>
<p>
Если вы хотите не только показывать товары в Telegram, но и активно общаться с клиентами,
рассылать новости, акции и уведомления для этого есть <strong>AcmeShop Pulse</strong>.
Это <strong>SaaS-платформа с месячной подпиской</strong>, которая полностью интегрируется
с вашим ECommerce-магазином и витриной AcmeShop.
</p>
<p><strong>С AcmeShop Pulse вы сможете:</strong></p>
<ul>
<li>📣 Делать массовые рассылки сообщений покупателям прямо в Telegram</li>
<li>📊 Анализировать эффективность сообщений и взаимодействие клиентов</li>
<li>🔗 Легко синхронизироваться с вашей витриной AcmeShop все данные остаются в одном месте</li>
</ul>
<p>
🧪 Платформа <strong>AcmeShop Pulse находится на ранней стадии тестирования</strong>.
Если вам интересно и вы хотите принять участи в тестировании интересно, свяжитесь со мной через
<a href="https://t.me/ocstore3" target="_blank">официальную группу AcmeShop в Telegram</a>.
</p>
</div>
<SettingsItem v-if="settings.items.pulse.api_key" label="Статистика за 7 дней">
<template #default>
<div v-if="stats" class="tw:space-y-4">
<div class="tw:flex tw:gap-3 tw:max-w-2xl">
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
<div class="tw:text-xs tw:font-medium tw:text-gray-700">В очереди</div>
<div
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-yellow-400 tw:to-yellow-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
<i class="fa fa-clock-o tw:text-white tw:text-xs"></i>
</div>
</div>
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
stats.pending
}}
</div>
<div class="tw:text-xs tw:text-gray-500">Ожидают отправки</div>
</div>
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
<div class="tw:text-xs tw:font-medium tw:text-gray-700">Отправлено</div>
<div
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-green-400 tw:to-green-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
<i class="fa fa-check-circle tw:text-white tw:text-xs"></i>
</div>
</div>
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
stats.sent
}}
</div>
<div class="tw:text-xs tw:text-gray-500">Успешно доставлено</div>
</div>
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
<div class="tw:text-xs tw:font-medium tw:text-gray-700">Ошибки</div>
<div
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-red-400 tw:to-red-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
<i class="fa fa-exclamation-circle tw:text-white tw:text-xs"></i>
</div>
</div>
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
stats.failed
}}
</div>
<div class="tw:text-xs tw:text-gray-500">Требуют внимания</div>
</div>
</div>
</div>
</template>
<template #help>
Статистика обновляется 1 раз в час
</template>
</SettingsItem>
<ItemInput label="API ключ"
v-model="settings.items.pulse.api_key"
placeholder="AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
>
Используется для обмена информацией по кампаниям, рассылкам, сбору метрик.
</ItemInput>
<ItemInput label="Размер пакета обработки"
v-model.number="settings.items.pulse.batch_size"
type="number"
placeholder="50"
>
Определяет, сколько событий отправляется в AcmeShop Pulse за один запуск фоновой задачи.
При большом значении события обрабатываются быстрее, но увеличивается нагрузка на сервер.
При малом значении нагрузка ниже, но обработка занимает больше времени.
Рекомендуемое значение: 50.
</ItemInput>
</div>
</template>
<script setup>
import {onMounted, ref} from "vue";
import {useSettingsStore} from "@/stores/settings.js";
import ItemInput from "@/components/Settings/ItemInput.vue";
import {apiGet} from "@/utils/http.js";
import SettingsItem from "@/components/SettingsItem.vue";
const settings = useSettingsStore();
const stats = ref(null);
const loadStats = async () => {
const response = await apiGet('getAcmeShopPulseStats');
if (response.success) {
stats.value = response.data;
}
};
onMounted(() => {
loadStats();
});
</script>
<style scoped>
.acmeshop-pulse-info {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 24px;
margin-bottom: 24px;
}
.acmeshop-pulse-info h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 16px;
color: #212529;
line-height: 1.4;
}
.acmeshop-pulse-info h3 a {
color: #dc3545;
font-weight: 700;
text-decoration: underline;
}
.acmeshop-pulse-info h3 a:hover {
text-decoration: none;
}
.acmeshop-pulse-info p {
margin-bottom: 16px;
line-height: 1.6;
color: #495057;
}
.acmeshop-pulse-info p:last-child {
margin-bottom: 0;
}
.acmeshop-pulse-info p strong {
color: #212529;
font-weight: 600;
}
.acmeshop-pulse-info ul {
margin: 16px 0;
padding-left: 24px;
}
.acmeshop-pulse-info ul li {
color: #495057;
position: relative;
padding-left: 8px;
}
.acmeshop-pulse-info ul li:last-child {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<SettingsItem label="Режим работы планировщика" doc-href="https://docs.acmeshop.pro/features/cron/">
<template #default>
<SelectButton
v-model="settings.items.cron.mode"
:options="cronModes"
optionLabel="label"
optionValue="value"
:allowEmpty="false"
/>
</template>
<template #help>
<div v-if="settings.items.cron.mode === 'disabled'" class="tw:text-red-600 tw:font-bold">
Все фоновые задачи отключены.
</div>
<div v-else-if="settings.items.cron.mode === 'cron_job_org'">
Задачи запускаются по вызову URL с сервиса <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>. Подходит для лёгких задач; при большом количестве товаров или тяжёлых операциях возможны таймауты.
</div>
<div v-else>
Рекомендуемый режим. Использует системный планировщик задач Linux.
</div>
</template>
<template #expandable>
<p>
<strong>Системный CRON (рекомендуется):</strong> Задачи выполняются через команду PHP в оболочке сервера (CLI), без HTTP и без ограничений по времени запроса. Не зависит от посещаемости сайта и подходит для любых объёмов данных, в том числе для тяжёлых задач и больших каталогов. Требует доступа к серверу (SSH или панель с CRON). Добавьте команду ниже в планировщик (обычно <code class="tw:px-1 tw:py-0.5 tw:bg-gray-100 tw:dark:bg-gray-800 tw:rounded">crontab -e</code>) для запуска каждые 5 минут.
</p>
<p>
<strong>cron-job.org:</strong> Внешний сервис по расписанию вызывает URL вашего сайта по HTTP. Не требует доступа к серверу удобно для shared-хостинга без CRON. Ограничения: выполнение идёт через веб-запрос, поэтому есть лимиты по времени (timeout у хостинга и у cron-job.org). <strong>Не подходит для тяжёлых сайтов</strong> (много товаров, большие каталоги, тяжёлые задачи): запрос может обрываться по таймауту, задачи не успеют завершиться. Выбирайте этот способ только если нет доступа к системному CRON и нагрузка на планировщик небольшая.
</p>
<p>
<strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач.
</p>
</template>
</SettingsItem>
<div class="tw:relative tw:mt-4">
<div
:class="[
'tw:transition-all tw:duration-200',
settings.items.cron.mode === 'disabled'
? 'tw:blur-[2px] tw:pointer-events-none tw:select-none'
: '',
]"
>
<SettingsItem label="Последний запуск CRON">
<template #default>
<div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2">
{{ lastRunDate }}
</div>
<div v-else class="tw:text-gray-500 tw:py-2">
Еще не запускался
</div>
</template>
<template #help>
Время последнего успешного выполнения планировщика задач.
</template>
</SettingsItem>
<SettingsItem
v-if="settings.items.cron.mode === 'system'"
label="Команда для CRON"
>
<template #default>
<InputGroup>
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard(cronCommand)"/>
<InputText readonly :model-value="cronCommand" class="tw:w-full"/>
</InputGroup>
</template>
<template #help>
Добавьте эту строку в конфигурацию CRON на вашем сервере (обычно `crontab -e`), чтобы запускать планировщик каждые
5 минут.
</template>
</SettingsItem>
<CronJobOrgUrlField v-if="settings.items.cron.mode === 'cron_job_org'"/>
<SettingsItem label="Задачи планировщика">
<template #default>
<ScheduledJobsList />
</template>
<template #help>
Включение и отключение задач планировщика. Дата последнего успешного запуска; при ошибке отображается иконка с подсказкой.
</template>
</SettingsItem>
</div>
<div
v-if="settings.items.cron.mode === 'disabled'"
class="tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:rounded-lg tw:bg-white/80 tw:dark:bg-gray-900/80 tw:z-10"
aria-hidden="true"
>
<span class="tw:text-lg tw:font-semibold tw:text-gray-600 tw:dark:text-gray-400">
Планировщик выключен
</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { useSettingsStore } from '@/stores/settings.js';
import SettingsItem from '@/components/SettingsItem.vue';
import ScheduledJobsList from '@/components/ScheduledJobsList.vue';
import CronJobOrgUrlField from '@/components/CronJobOrgUrlField.vue';
import SelectButton from 'primevue/selectbutton';
import InputText from 'primevue/inputtext';
import Button from 'primevue/button';
import InputGroup from 'primevue/inputgroup';
import { toastBus } from '@/utils/toastHelper.js';
const settings = useSettingsStore();
const cronModes = [
{value: 'system', label: 'Системный CRON (Linux)'},
{value: 'cron_job_org', label: 'cron-job.org'},
{value: 'disabled', label: 'Выключено'},
];
const cronCommand = computed(() => {
const cliPath = settings.items.cron?.cli_path;
return cliPath
? `*/5 * * * * php ${cliPath} schedule:run`
: 'Путь не определен. Проверьте конфигурацию модуля.';
});
const lastRunDate = computed(() => settings.items.cron?.last_run);
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
toastBus.emit('show', {
severity: 'success',
summary: 'Скопировано',
detail: 'Команда скопирована в буфер обмена',
life: 2000,
});
} catch (err) {
toastBus.emit('show', {
severity: 'error',
summary: 'Ошибка',
detail: 'Не удалось скопировать текст',
life: 2000,
});
}
}
</script>

View File

@@ -0,0 +1,706 @@
<template>
<div>
<DataTable
:value="customers"
:loading="loading"
paginator
:rows="20"
:rowsPerPageOptions="[10, 20, 50, 100]"
:sortField="lazyParams.sortField"
:sortOrder="lazyParams.sortOrder"
showGridlines
stripedRows
size="small"
removableSort
:globalFilterFields="['telegram_user_id', 'username', 'first_name', 'last_name', 'language_code']"
v-model:filters="filters"
filterDisplay="menu"
:lazy="true"
:totalRecords="totalRecords"
@page="onPage"
@sort="onSort"
@filter="onFilter"
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:currentPageReportTemplate="`Показано {first} - {last} из {totalRecords} записей`"
>
<template #header>
<div class="tw:flex tw:flex-wrap tw:items-center tw:justify-between tw:gap-2">
<div class="tw:flex tw:items-center tw:gap-2">
<Button
icon="fa fa-columns"
:label="`Колонки (${selectedColumns.length}/${columns.length})`"
@click="toggleColumnsPanel"
size="small"
/>
<OverlayPanel ref="columnsPanel">
<div class="tw:flex tw:flex-col tw:gap-2 tw:min-w-[200px]">
<div class="tw:flex tw:gap-2 tw:mb-2">
<Button
label="Выбрать все"
size="small"
severity="secondary"
outlined
@click="selectAllColumns"
class="tw:flex-1"
/>
<Button
label="Снять все"
size="small"
severity="secondary"
outlined
@click="deselectAllColumns"
class="tw:flex-1"
/>
</div>
<div
v-for="col in columns"
:key="col.field"
class="tw:flex tw:items-center tw:gap-2"
>
<Checkbox
:inputId="col.field"
:modelValue="selectedColumns.some(c => c.field === col.field)"
@update:modelValue="(val) => toggleColumn(col, val)"
:binary="true"
/>
<label :for="col.field" class="tw:cursor-pointer">{{ col.header }}</label>
</div>
</div>
</OverlayPanel>
<Button icon="fa fa-refresh" @click="loadCustomers" v-tooltip.top="'Обновить таблицу'"
size="small"/>
<Button icon="fa fa-times-circle" label="Сбросить фильтры" @click="resetFilters"
v-tooltip.top="'Сбросить все фильтры'" size="small"/>
</div>
<IconField>
<InputIcon class="fa fa-search"/>
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..."
@input="onGlobalSearch"/>
</IconField>
</div>
</template>
<Column header="Действия" :exportable="false" headerStyle="width: 5rem">
<template #body="{ data }">
<Button
icon="fa fa-paper-plane"
severity="secondary"
text
rounded
@click="openMessageDialog(data)"
v-tooltip.top="'Отправить сообщение пользователю в Telegram'"
/>
</template>
</Column>
<Column v-for="col in selectedColumns" :key="col.field" :field="col.field"
:sortable="col.sortable" :dataType="col.dataType"
:showFilterMenu="col.filterable !== false">
<template #header>
<div class="tw:flex tw:items-center tw:gap-1">
<span>{{ col.header }}</span>
<i
v-if="col.help"
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help"
v-tooltip.top="col.help"
></i>
</div>
</template>
<template #body="{ data }">
<template v-if="col.field === 'id'">{{ data.id }}</template>
<template v-else-if="col.field === 'telegram_user_id'">
{{ data.telegram_user_id }}
</template>
<template v-else-if="col.field === 'tracking_id'">
<code>{{ data.tracking_id }}</code>
</template>
<template v-else-if="col.field === 'username'">
<div class="tw:flex tw:items-center tw:gap-2">
<div v-if="data.photo_url" class="tw:relative">
<img
:src="data.photo_url"
:alt="data.username || 'Avatar'"
class="tw:w-6 tw:h-6 tw:rounded-full tw:object-cover"
@error="handleImageError"
/>
</div>
<i v-else class="fa fa-user tw:text-gray-400"></i>
<span v-if="data.username">@{{ data.username }}</span>
<span v-else></span>
</div>
</template>
<template v-else-if="col.field === 'first_name'">{{ data.first_name || '' }}</template>
<template v-else-if="col.field === 'last_name'">{{ data.last_name || '' }}</template>
<template v-else-if="col.field === 'language_code'">
<span v-if="data.language_code">
<i class="fa fa-globe"></i> {{ data.language_code.toUpperCase() }}
</span>
<span v-else></span>
</template>
<template v-else-if="col.field === 'is_premium'">
<i v-if="data.is_premium" class="fa fa-star" v-tooltip.top="'Премиум пользователь'"></i>
<span v-else></span>
</template>
<template v-else-if="col.field === 'orders_count'">
<span>{{ data.orders_count }}</span>
</template>
<template v-else-if="col.field === 'oc_customer_id'">
<span v-if="data.oc_customer_id">{{ data.oc_customer_id }}</span>
<span v-else></span>
</template>
<template v-else-if="col.field === 'last_seen_at'">
<span v-if="data.last_seen_at">
<i class="fa fa-clock-o"></i> {{ formatDate(data.last_seen_at) }}
</span>
<span v-else></span>
</template>
<template v-else-if="col.field === 'privacy_consented_at'">
<span v-if="data.privacy_consented_at">
<i class="fa fa-clock-o"></i> {{ formatDate(data.privacy_consented_at) }}
</span>
<span v-else></span>
</template>
<template v-else-if="col.field === 'created_at'">
<i class="fa fa-calendar"></i> {{ formatDate(data.created_at) }}
</template>
</template>
<template #filter="{ filterModel }">
<template v-if="col.field === 'telegram_user_id'">
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по ID"
class="p-column-filter"/>
</template>
<template v-else-if="col.field === 'username'">
<InputText v-model="filterModel.value" type="text"
placeholder="Поиск по имени пользователя" class="p-column-filter"/>
</template>
<template v-else-if="col.field === 'first_name'">
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени"
class="p-column-filter"/>
</template>
<template v-else-if="col.field === 'last_name'">
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии"
class="p-column-filter"/>
</template>
<template
v-else-if="['last_seen_at', 'created_at', 'privacy_consented_at'].includes(col.field)">
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
</template>
<template v-else-if="col.field === 'orders_count'">
<InputNumber v-model="filterModel.value"/>
</template>
<template v-else-if="col.field === 'is_premium'">
<Dropdown
v-model="filterModel.value"
:options="premiumFilterOptions"
optionLabel="label"
optionValue="value"
placeholder="Любой"
class="p-column-filter"
/>
</template>
</template>
</Column>
<template #empty>
<div style="text-align: center; padding: 2rem;">
<i class="fa fa-users" style="font-size: 3rem; color: #ccc; margin-bottom: 1rem;"></i>
<div>Нет данных о кастомерах</div>
<div style="font-size: 0.9rem; color: #999; margin-top: 0.5rem;">
Пользователи появятся здесь после первого входа в Telegram Mini App
</div>
</div>
</template>
<template #loading>
<div style="text-align: center; padding: 2rem;">
<i class="fa fa-spinner fa-spin" style="font-size: 2rem;"></i>
<div style="margin-top: 1rem;">Загрузка данных...</div>
</div>
</template>
</DataTable>
<Dialog
v-model:visible="showMessageDialog"
modal
header="Отправить сообщение"
:style="{ width: '500px' }"
:closable="true"
>
<div style="margin-bottom: 1rem;">
<div style="margin-bottom: 0.5rem; font-weight: 600;">
Получатель:
</div>
<div v-if="selectedCustomer">
<div v-if="selectedCustomer.username">
<i class="fa fa-user"></i> @{{ selectedCustomer.username }}
</div>
<div v-if="selectedCustomer.first_name || selectedCustomer.last_name">
{{ selectedCustomer.first_name }} {{ selectedCustomer.last_name }}
</div>
<div style="font-size: 0.9rem; color: #666;">
ID: {{ selectedCustomer.telegram_user_id }}
</div>
<div v-if="!selectedCustomer.allows_write_to_pm"
style="color: #f59e0b; margin-top: 0.5rem;">
<i class="fa fa-exclamation-triangle"></i> Пользователь не разрешил писать ему в PM
</div>
</div>
</div>
<div style="margin-bottom: 1rem;">
<label for="message" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
Сообщение:
</label>
<Textarea
id="message"
v-model="messageText"
:rows="5"
:disabled="!selectedCustomer || !selectedCustomer.allows_write_to_pm"
placeholder="Введите текст сообщения..."
style="width: 100%;"
/>
</div>
<template #footer>
<Button
label="Отмена"
icon="fa fa-times"
severity="secondary"
@click="closeMessageDialog"
:disabled="sendingMessage"
/>
<Button
label="Отправить"
icon="fa fa-paper-plane"
@click="sendMessage"
:loading="sendingMessage"
:disabled="!messageText || !selectedCustomer || !selectedCustomer.allows_write_to_pm"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import {onMounted, ref} from 'vue';
import {FilterMatchMode, FilterOperator} from '@primevue/core/api';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
import DatePicker from 'primevue/datepicker';
import Dropdown from 'primevue/dropdown';
import InputText from 'primevue/inputtext';
import Dialog from 'primevue/dialog';
import Textarea from 'primevue/textarea';
import OverlayPanel from 'primevue/overlaypanel';
import Checkbox from 'primevue/checkbox';
import Button from 'primevue/button';
import InputNumber from 'primevue/inputnumber';
import {apiPost} from '@/utils/http.js';
import {IconField, InputIcon, useToast} from 'primevue';
const toast = useToast();
const customers = ref([]);
const loading = ref(false);
const totalRecords = ref(0);
const showMessageDialog = ref(false);
const selectedCustomer = ref(null);
const messageText = ref('');
const sendingMessage = ref(false);
const columns = ref([
{field: 'id', header: '№', sortable: true, filterable: false, visible: true},
{
field: 'telegram_user_id',
header: 'ID в Telegram',
sortable: true,
filterable: true,
visible: false
},
{
field: 'tracking_id',
header: 'Tracking ID',
sortable: false,
filterable: true,
visible: false,
help: 'Tracking ID это публичный уникальный идентификатор покупателя, используется в рекламных кампаниях для отслеживания активности.',
},
{field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true},
{field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true},
{field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true},
{
field: 'language_code',
header: 'Язык интерфейса',
sortable: true,
filterable: false,
visible: false
},
{
field: 'orders_count',
header: 'Кол-во заказов',
sortable: true,
filterable: true,
dataType: 'numeric',
visible: true,
help: 'Общее количество Telegram заказов за всё время',
},
{field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true},
{
field: 'oc_customer_id',
header: 'ID покупателя',
sortable: true,
filterable: false,
visible: false
},
{
field: 'last_seen_at',
header: 'Последний визит',
sortable: true,
dataType: 'date',
filterable: true,
visible: true,
help: 'Показывает, когда пользователь последний раз открывал Telegram магазин.',
},
{
field: 'privacy_consented_at',
header: 'Согласие ПД',
sortable: true,
dataType: 'date',
filterable: true,
visible: true,
help: 'Показывает, когда пользователь дал согласие на обработку персональных данных.',
},
{
field: 'created_at',
header: 'Дата регистрации',
sortable: true,
dataType: 'date',
filterable: true,
visible: true,
help: 'Показывает, когда пользователь впервые открыл Telegram магазин.',
},
]);
const selectedColumns = ref(columns.value.filter(col => col.visible !== false));
const columnsPanel = ref(null);
const globalSearchValue = ref('');
let searchTimeout = null;
function toggleColumnsPanel(event) {
columnsPanel.value.toggle(event);
}
function toggleColumn(col, checked) {
// Сохраняем порядок колонок из исходного массива columns
const selectedFields = new Set(selectedColumns.value.map(c => c.field));
if (checked) {
selectedFields.add(col.field);
} else {
selectedFields.delete(col.field);
}
// Пересоздаем массив, сохраняя исходный порядок из columns
selectedColumns.value = columns.value.filter(c => selectedFields.has(c.field));
}
function selectAllColumns() {
selectedColumns.value = [...columns.value];
}
function deselectAllColumns() {
selectedColumns.value = [];
}
const premiumFilterOptions = [
{label: 'Любой', value: null},
{label: 'Нет', value: false},
{label: 'Да', value: true},
];
const lazyParams = ref({
first: 0,
rows: 20,
page: 1,
sortField: 'last_seen_at',
sortOrder: -1,
filters: {},
});
const filters = ref({
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
telegram_user_id: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
},
username: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
},
first_name: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
},
last_name: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
},
orders_count: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}],
},
is_premium: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
},
created_at: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
},
last_seen_at: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
},
privacy_consented_at: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
},
});
function processFiltersForBackend(filtersObj) {
const processed = JSON.parse(JSON.stringify(filtersObj));
// Обрабатываем фильтры по датам
const dateFields = ['created_at', 'last_seen_at', 'privacy_consented_at'];
dateFields.forEach(field => {
if (processed[field] && processed[field].constraints) {
processed[field].constraints.forEach(constraint => {
if (constraint.value && ['dateIs', 'dateIsNot', 'dateBefore', 'dateAfter'].includes(constraint.matchMode)) {
// Преобразуем дату в формат YYYY-MM-DD, используя локальное время
const date = new Date(constraint.value);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
constraint.value = `${year}-${month}-${day}`;
}
});
}
});
return processed;
}
async function loadCustomers(event = null) {
loading.value = true;
try {
const processedFilters = processFiltersForBackend(filters.value);
const params = {
page: lazyParams.value.page,
rows: lazyParams.value.rows,
sortField: lazyParams.value.sortField,
sortOrder: lazyParams.value.sortOrder === -1 ? 'DESC' : 'ASC',
filters: processedFilters,
};
const result = await apiPost('getTelegramCustomers', params);
if (result.success && result.data) {
// apiPost возвращает полный ответ сервера, а apiGet возвращает response.data.data
// Поэтому здесь нужно проверить, есть ли вложенный data
const responseData = result.data.data ? result.data.data : result.data;
customers.value = Array.isArray(responseData) ? responseData : (responseData.data || []);
totalRecords.value = responseData.totalRecords || 0;
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: result.error || 'Не удалось загрузить данные',
life: 3000,
});
}
} catch (error) {
console.error('Ошибка при загрузке кастомеров:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Произошла ошибка при загрузке данных',
life: 3000,
});
} finally {
loading.value = false;
}
}
function onPage(event) {
lazyParams.value.first = event.first;
lazyParams.value.rows = event.rows;
lazyParams.value.page = event.page + 1;
loadCustomers(event);
}
function onSort(event) {
lazyParams.value.sortField = event.sortField;
lazyParams.value.sortOrder = event.sortOrder;
lazyParams.value.page = 1;
lazyParams.value.first = 0;
loadCustomers(event);
}
function onFilter(event) {
filters.value = event.filters;
lazyParams.value.page = 1;
lazyParams.value.first = 0;
loadCustomers(event);
}
function onGlobalSearch() {
// Обновляем глобальный фильтр
filters.value.global.value = globalSearchValue.value || null;
// Сбрасываем на первую страницу
lazyParams.value.page = 1;
lazyParams.value.first = 0;
// Debounce: очищаем предыдущий таймер
if (searchTimeout) {
clearTimeout(searchTimeout);
}
// Устанавливаем новый таймер для отправки запроса через 300ms после последнего ввода
searchTimeout = setTimeout(() => {
loadCustomers();
}, 800);
}
function resetFilters() {
globalSearchValue.value = '';
filters.value = {
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
telegram_user_id: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
},
username: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
},
first_name: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
},
last_name: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
},
orders_count: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
},
is_premium: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
},
created_at: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
},
last_seen_at: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
},
privacy_consented_at: {
operator: FilterOperator.AND,
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
},
};
lazyParams.value.page = 1;
lazyParams.value.first = 0;
loadCustomers();
}
function handleImageError(event) {
// Скрываем изображение при ошибке загрузки
event.target.style.display = 'none';
}
function formatDate(dateString) {
if (!dateString) return '—';
const date = new Date(dateString);
return date.toLocaleString('ru-RU', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function openMessageDialog(customer) {
selectedCustomer.value = customer;
messageText.value = '';
showMessageDialog.value = true;
}
function closeMessageDialog() {
showMessageDialog.value = false;
selectedCustomer.value = null;
messageText.value = '';
}
async function sendMessage() {
if (!selectedCustomer.value || !messageText.value.trim()) {
return;
}
if (!selectedCustomer.value.allows_write_to_pm) {
toast.add({
severity: 'warn',
summary: 'Предупреждение',
detail: 'Пользователь не разрешил писать ему в PM',
life: 3000,
});
return;
}
sendingMessage.value = true;
try {
const result = await apiPost('sendMessageToCustomer', {
id: selectedCustomer.value.id,
message: messageText.value.trim(),
});
if (result.success && result.data?.success) {
toast.add({
severity: 'success',
summary: 'Успешно',
detail: result.data?.message || 'Сообщение отправлено',
life: 3000,
});
closeMessageDialog();
} else {
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: result.data?.error || result.error || 'Не удалось отправить сообщение',
life: 3000,
});
}
} catch (error) {
console.error('Ошибка при отправке сообщения:', error);
toast.add({
severity: 'error',
summary: 'Ошибка',
detail: 'Произошла ошибка при отправке сообщения',
life: 3000,
});
} finally {
sendingMessage.value = false;
}
}
onMounted(() => {
loadCustomers();
});
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div v-if="isLoading" class="tw:flex tw:justify-center tw:items-center tw:h-full">
<i class="fa fa-spinner fa-spin tw:text-4xl tw:text-blue-500"></i>
</div>
<div v-else class="tw:h-full">
<FormBuilder
v-model="settings.items.forms.checkout.schema"
v-model:isCustom="settings.items.forms.checkout.is_custom"
/>
</div>
</template>
<script setup>
import {ref} from 'vue';
import FormBuilder from '@/components/FormBuilder/FormBuilder.vue';
import {useSettingsStore} from "@/stores/settings.js";
const formSchema = ref([]);
const isCustom = ref(false);
const isLoading = ref(false);
const settings = useSettingsStore();
</script>

View File

@@ -0,0 +1,97 @@
<template>
<ItemBool label="Статус" v-model="settings.items.app.app_enabled">
Если выключено, покупатели в Telegram увидят сообщение, что магазин временно закрыт.
Заказы и просмотр товаров будут недоступны.
</ItemBool>
<ItemInput label="Название приложения"
v-model="settings.items.app.app_name"
placeholder="Без названия"
>
Отображается в заголовке Telegram Mini App при запуске, а также используется как подпись
под иконкой, если пользователь добавит приложение на главный экран своего устройства.
Рекомендуется короткое и понятное название (до 20 символов).
Если оставить пустым, то название выводиться не будет.
</ItemInput>
<ItemImage label="Иконка приложения" v-model="settings.items.app.app_icon">
Изображение, которое будет отображаться в Telegram Mini App.
</ItemImage>
<ItemInput label="Политика конфиденциальности"
v-model="settings.items.app.privacy_policy_link"
placeholder="https://your-site/privacy-polisy"
>
Укажите ссылку на страницу с вашей Политикой конфиденциальности.
Она будет показываться пользователям для получения согласия на обработку персональных данных.
Вы сможете видеть, кто согласился, на странице с
<RouterLink to="/customers">Telegram покупателями</RouterLink>
.
</ItemInput>
<ItemSelect label="Светлая тема" v-model="settings.items.app.theme_light" :items="themes">
Выберите стиль, который будет использоваться при отображении вашего магазина
в Telegram для дневного режима.
<a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">
Посмотреть как выглядят темы
</a>
</ItemSelect>
<ItemSelect label="Тёмная тема" v-model="settings.items.app.theme_dark" :items="themes">
Выберите стиль, который будет использоваться при отображении вашего магазина
в Telegram для ночного режима.
<a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">
Посмотреть как выглядят темы
</a>
</ItemSelect>
<ItemBool label="Режим разработчика" v-model="settings.items.app.app_debug">
Режим разработчика. Рекомендуется включать только по необходимости.
В остальных случаях, для нормальной работы магазина, должен быть выключен.
</ItemBool>
<ItemSelect label="Соотношение сторон" v-model="settings.items.app.image_aspect_ratio" :items="aspectRatioOptions">
Выберите соотношение сторон для изображений товаров. Это глобальная настройка, которая будет применяться ко всем изображениям в списках товаров: карусель товаров, лента товаров, результаты поиска.
</ItemSelect>
<ItemSelect label="Алгоритм обрезки" v-model="settings.items.app.image_crop_algorithm" :items="cropAlgorithmOptions">
Выберите алгоритм обрезки изображений. Эта настройка применяется глобально ко всем изображениям в списках товаров (карусель товаров, лента товаров, результаты поиска):
<ul class="tw:list-disc tw:ml-5 tw:mt-2">
<li><strong>Cover</strong> - обрезает изображение, сохраняя пропорции, чтобы заполнить весь размер (может обрезать края)</li>
<li><strong>Contain</strong> - вписывает изображение в размер, сохраняя пропорции (может добавить пустые поля)</li>
<li><strong>Resize</strong> - изменяет размер изображения с сохранением пропорций (без обрезки)</li>
</ul>
</ItemSelect>
<ItemBool label="Тактильная обратная связь (Haptic Feedback)" v-model="settings.items.app.haptic_enabled">
Включить виброотклик при взаимодействии с элементами интерфейса. Если выключено, тактильная обратная связь не будет использоваться.
</ItemBool>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import ItemBool from "@/components/Settings/ItemBool.vue";
import ItemImage from "@/components/Settings/ItemImage.vue";
import ItemSelect from "@/components/Settings/ItemSelect.vue";
import ItemInput from "@/components/Settings/ItemInput.vue";
const settings = useSettingsStore();
const themes = JSON.parse(window.AcmeShop.themes);
const aspectRatioOptions = {
'1:1': '1:1 - Квадрат (универсально, аксессуары, мелкие товары)',
'4:5': '4:5 - Вертикальное (одежда, обувь, вертикальные товары)',
'3:4': '3:4 - Вертикальное (одежда, обувь, вертикальные товары)',
'2:3': '2:3 - Высокое вертикальное (цветы, высокие предметы)',
};
const cropAlgorithmOptions = {
'cover': 'Cover - Обрезать с сохранением пропорций',
'contain': 'Contain - Вписать с сохранением пропорций',
'resize': 'Resize - Изменить размер с сохранением пропорций',
};
</script>
<style scoped>
</style>

View File

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

View File

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

View File

@@ -0,0 +1,53 @@
<template>
<ItemBool
label="Яндекс.Метрика"
v-model="settings.items.metrics.yandex_metrika_enabled"
>
Задействовать Яндекс.Метрику для Telegram магазина.
</ItemBool>
<ItemInput
label="Номер счётчика Яндекс.Метрика"
v-model="settings.items.metrics.yandex_metrika_counter"
placeholder="Вставьте код счётчика Яндекс.Метрики"
>
<ButtonGroup>
<Button
v-if="settings.items.metrics.yandex_metrika_enabled && settings.items.metrics.yandex_metrika_counter"
as="a"
:href="ymCheckUrl"
target="_blank"
variant="text"
:disabled="settings.items.app.app_debug === false"
v-tooltip.top="'Чтобы проверить интеграцию, включите режим разработчика на вкладке Общие и сохраните настройки.'"
>
Проверить интеграцию
</Button>
<Button
as="a"
href="https://acme-inc.github.io/docs/analitycs/start/"
target="_blank"
variant="text"
>
Как получить номер счётчика <i class="fa fa-external-link"></i>
</Button>
</ButtonGroup>
</ItemInput>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import ItemBool from "@/components/Settings/ItemBool.vue";
import ItemInput from "@/components/Settings/ItemInput.vue";
import {Button, ButtonGroup} from 'primevue';
import {computed} from "vue";
const settings = useSettingsStore();
const ymCheckUrl = computed(() => {
const url = settings.items.telegram.mini_app_url.replace(/#\/$/, '');
return `${url}?_ym_status-check=${settings.items.metrics.yandex_metrika_counter}&_ym_lang=ru`;
});
</script>

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

View File

@@ -0,0 +1,65 @@
<template>
<ItemToggleButton
label="Сценарий взаимодействия с товаром"
v-model="settings.items.store.product_interaction_mode"
:items="productInteractionOptions"
>
<p>Выберите, что будет происходить при нажатии на кнопку товара:
<br><strong>Создание заявки / заказа</strong> Пользователи смогут добавить товар и оформить заявку на покупку прямо в Telegram. Заказ фиксируется в ECommerce, а дальнейшая работа с клиентом происходит вручную.
<br><strong>Кнопка связи с менеджером</strong> пользователи увидят кнопку для связи с менеджером в Telegram. Менеджера можно указать в поле "Username менеджера" ниже.
<br><strong>Открытие товара на сайте</strong> кнопка откроет страницу товара на основном сайте ECommerce во внешнем браузере.</p>
</ItemToggleButton>
<ItemInput
label="Username менеджера"
v-model="settings.items.store.manager_username"
placeholder="@username"
>
<p>Укажите username (например, @username) для связи с менеджером. Это может быть личный аккаунт или группа, куда покупатели могут писать. Используется только при выборе режима "Кнопка связи с менеджером".</p>
</ItemInput>
<ItemBool label="Промокоды" v-model="settings.items.store.feature_coupons">
<p>
Позволяет использовать стандартные
<a :href="`/admin/index.php?route=marketing/coupon&user_token=${userToken}`"
target="_blank">купоны ECommerce</a>
для предоставления скидок при оформлении заказа.</p>
</ItemBool>
<ItemBool label="Подарочные сертификаты" v-model="settings.items.store.feature_vouchers">
<p>
Позволяет использовать стандартные
<a :href="`/admin/index.php?route=sale/voucher&user_token=${userToken}`"
target="_blank">подарочные сертификаты ECommerce</a> при оформлении заказа.</p>
</ItemBool>
<ItemBool label="Показывать кнопку «Показать товары из текущей категории»" v-model="settings.items.store.show_category_products_button">
<p>Включите, чтобы пользователи видели кнопку «Показать товары из "название текущей категории"» на странице категории, если у неё есть дочерние категории. Настройка работает только для страниц категорий с дочерними категориями, при отключении кнопка скрыта.</p>
</ItemBool>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import ItemBool from "@/components/Settings/ItemBool.vue";
import ItemSelect from "@/components/Settings/ItemSelect.vue";
import ItemInput from "@/components/Settings/ItemInput.vue";
import ItemToggleButton from "@/components/Settings/ItemToggleButton.vue";
import ItemProductsSelect from "@/components/Settings/ItemProductsSelect.vue";
import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue";
const settings = useSettingsStore();
const mainpage_categories_options = {
no_categories: 'Отображать только кнопку "Каталог"',
latest10: 'Последние 10 категорий',
featured: 'Избранные категории (задать в поле ниже)',
};
const productInteractionOptions = {
order: 'Создание заявки / заказа',
manager: 'Кнопка связи с менеджером',
browser: 'Открытие товара на сайте',
};
const userToken = window.AcmeShop.user_token;
</script>

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,46 @@
<template>
<ItemInput label="Текст в конце списка товаров" v-model="settings.items.texts.text_no_more_products">
Текст, отображаемый в конце списка, когда больше нет доступных товаров.
Покупатель дошел до конца списка.
</ItemInput>
<ItemInput label="Текст пустой корзины" v-model="settings.items.texts.text_empty_cart">
Текст, отображаемый на странице просмотра корзины, если в ней нет товаров.
</ItemInput>
<ItemInput label="Текст для успешного заказа" v-model="settings.items.texts.text_order_created_success">
Текст, отображаемый при успешном создании заказа.
</ItemInput>
<ItemInput label="Текст вместо нулевой цены" v-model="settings.items.texts.zero_price_text" placeholder="0.00р.">
Текст, который будет выводиться вместо цены, в случае если цена = 0.
Если текст отсутствует, то будет выводиться нулевая цена по умолчанию.
</ItemInput>
<ItemTextarea label="Приветственный текст" v-model="settings.items.texts.start_message" placeholder="Например, добро пожаловать в наш магазин.">
Сообщение, которое выводится в приветственном сообщении покупателю (когда он
запустит бота через `/start`). Можно использовать HTML разметку, которую
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">
поддерживает Telegram <i class="fa fa-external-link"></i>
</a>. Можно использовать <a href="https://getemoji.com/" target="_blank">
эмодзи <i class="fa fa-external-link"></i>
</a>.
</ItemTextarea>
<ItemInput label="Текст кнопки приветственного сообщения" v-model="settings.items.texts.start_button.text">
Текст на кнопке приветственного сообщения, которая открывает магазин.
</ItemInput>
<ItemInput label="Текст кнопки связи с менеджером" v-model="settings.items.texts.text_manager_button" placeholder="Связаться с менеджером">
Текст на кнопке для связи с менеджером на странице товара. Используется только при выборе режима "Кнопка связи с менеджером" в настройках витрины.
</ItemInput>
</template>
<script setup>
import {useSettingsStore} from "@/stores/settings.js";
import ItemInput from "@/components/Settings/ItemInput.vue";
import ItemImage from "@/components/Settings/ItemImage.vue";
import ItemTextarea from "@/components/Settings/ItemTextarea.vue";
const settings = useSettingsStore();
</script>

View File

@@ -0,0 +1,9 @@
module.exports = {
prefix: 'tw:',
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}',
'./templates/**/*.twig',
'./views/**/*.php',
],
};

View File

@@ -0,0 +1,48 @@
import {fileURLToPath, URL} from 'node:url';
import {defineConfig} from 'vite';
import vue from '@vitejs/plugin-vue';
import vueDevTools from 'vite-plugin-vue-devtools';
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
build: {
manifest: false,
sourcemap: false,
outDir: '../../module/oc_telegram_shop/upload/admin/view/javascript/acmeshop',
emptyOutDir: true, // also necessary
rollupOptions: {
input: {
acmeshop: '/src/main.js',
},
output: {
entryFileNames: `[name].js`,
chunkFileNames: `[name].js`,
assetFileNames: `[name].[ext]`,
}
}
},
server: {
host: 'localhost', // или '0.0.0.0' для доступа извне
port: 3000, // Порт, на котором будет запущен Vite Dev Server
cors: true, // Разрешить CORS (для разработки)
hmr: {
host: 'localhost',
port: 3000,
protocol: 'ws',
},
},
});

15
frontend/spa/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8"/>
<link rel="icon" type="image/svg+xml" href="/vite.svg"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>AcmeShop</title>
</head>
<body>
<div id="app"></div>
<div id="app-error"></div>
<script src="https://telegram.org/js/telegram-web-app.js?59"></script>
<script type="module" src="/src/main.js"></script>
</body>
</html>

5498
frontend/spa/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
frontend/spa/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "tg-mini-app-shop",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview",
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage",
"test:run": "vitest run"
},
"dependencies": {
"@formkit/core": "^1.6.9",
"@formkit/icons": "^1.6.9",
"@formkit/vue": "^1.6.9",
"@heroicons/vue": "^2.2.0",
"@tailwindcss/vite": "^4.1.16",
"@vueuse/core": "^13.9.0",
"crypto-js": "^4.2.0",
"js-md5": "^0.8.3",
"ofetch": "^1.4.1",
"pinia": "^3.0.3",
"swiper": "^12.1.2",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vitest/ui": "^4.0.8",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.21",
"daisyui": "^5.3.10",
"jsdom": "^27.1.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.16",
"terser": "^5.44.0",
"vite": "^7.1.12",
"vitest": "^4.0.8"
}
}

158
frontend/spa/src/App.vue Normal file
View File

@@ -0,0 +1,158 @@
<template>
<div class="app-container">
<header class="app-header bg-base-200 w-full"></header>
<section class="app-content">
<div class="fixed inset-0 z-50 bg-white flex items-center justify-center text-center p-4
[@supports(color:oklch(0%_0_0))]:hidden">
<BrowserNotSupported/>
</div>
<AppDebugMessage v-if="settings.app_debug"/>
<RouterView v-slot="{ Component, route }">
<KeepAlive include="Home,Products" :key="filtersStore.paramsHashForRouter">
<component :is="Component" :key="route.fullPath"/>
</KeepAlive>
</RouterView>
<PrivacyPolicy v-if="! settings.is_privacy_consented"/>
<Dock v-if="isAppDockShown"/>
<div class="dock-spacer bg-base-100 z-50"></div>
<div
v-if="swiperBack.isActive.value"
class="fixed top-1/2 left-0 z-50
w-20
h-20
-translate-y-1/2
-translate-x-18
flex items-center justify-end
shadow-xl
rounded-full
py-2
text-primary-content
"
:class="{
'bg-primary': swiperBack.deltaX.value < swiperBack.ACTIVATION_THRESHOLD,
'bg-accent': swiperBack.deltaX.value >= swiperBack.ACTIVATION_THRESHOLD,
}"
:style="{ transform: `translate(${easeOut(swiperBack.deltaX.value/10, 30)}px, -50%)` }"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
</div>
</section>
</div>
</template>
<script setup>
import {computed, onMounted, onUnmounted, watch} from "vue";
import {useRoute, useRouter} from "vue-router";
import {useSettingsStore} from "@/stores/SettingsStore.js";
import {useProductFiltersStore} from "@/stores/ProductFiltersStore.js";
import {useKeyboardStore} from "@/stores/KeyboardStore.js";
import Dock from "@/components/Dock.vue";
import AppDebugMessage from "@/components/AppDebugMessage.vue";
import PrivacyPolicy from "@/components/PrivacyPolicy.vue";
import BrowserNotSupported from "@/BrowserNotSupported.vue";
import {useSwipeBack} from "@/composables/useSwipeBack.js";
import {useHapticFeedback} from "@/composables/useHapticFeedback.js";
const router = useRouter();
const route = useRoute();
const settings = useSettingsStore();
const filtersStore = useProductFiltersStore();
const keyboardStore = useKeyboardStore();
const backButton = window.Telegram.WebApp.BackButton;
const haptic = useHapticFeedback();
const swiperBack = useSwipeBack();
const routesToHideAppDock = [
'product.show',
'checkout',
'order_created',
'filters',
];
function easeOut(value, max) {
const x = Math.min(Math.abs(value) / max, 1)
const eased = 1 - (1 - x) ** 3
return Math.sign(value) * eased * max
}
const isAppDockShown = computed(() => {
if (routesToHideAppDock.indexOf(route.name) === -1) {
// Скрываем Dock, если клавиатура открыта на странице поиска
if (route.name === 'search' && keyboardStore.isOpen) {
return false;
}
return true;
}
return false;
});
function navigateBack() {
haptic.impactOccurred('light');
router.back();
}
watch(
() => route.name,
() => {
if (route.name === 'home') {
backButton.hide();
backButton.offClick(navigateBack);
} else {
backButton.show();
backButton.onClick(navigateBack);
}
},
{immediate: true}
);
function handleClickOutside(e) {
if (!e.target.closest('input,select,textarea')) {
document.activeElement?.blur();
}
}
watch(
() => route.name,
(name) => {
let height = '72px'; // дефолт
if (name === 'product.show') height = '146px';
document.documentElement.style.setProperty('--dock-height', height);
},
{ immediate: true }
);
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
});
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.dock-spacer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: calc(
var(--tg-content-safe-area-inset-bottom, 0px)
+ var(--tg-safe-area-inset-bottom, 0px)
);
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,7 @@
<template>
<LoadingFullScreen text="Загрузка приложения..."/>
</template>
<script setup>
import LoadingFullScreen from "@/components/LoadingFullScreen.vue";
</script>

Some files were not shown because too many files have changed in this diff Show More