Squashed commit message
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled
Some checks failed
Telegram Mini App Shop Builder / Compute version metadata (push) Has been cancelled
Telegram Mini App Shop Builder / Run Frontend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run Backend tests (push) Has been cancelled
Telegram Mini App Shop Builder / Run PHP_CodeSniffer (push) Has been cancelled
Telegram Mini App Shop Builder / Build module. (push) Has been cancelled
Telegram Mini App Shop Builder / release (push) Has been cancelled
This commit is contained in:
8
frontend/admin/.editorconfig
Normal file
8
frontend/admin/.editorconfig
Normal 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
1
frontend/admin/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
36
frontend/admin/.gitignore
vendored
Normal file
36
frontend/admin/.gitignore
vendored
Normal 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__/
|
||||
6
frontend/admin/.prettierrc.json
Normal file
6
frontend/admin/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
28
frontend/admin/eslint.config.js
Normal file
28
frontend/admin/eslint.config.js
Normal 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
13
frontend/admin/index.html
Normal 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>
|
||||
8
frontend/admin/jsconfig.json
Normal file
8
frontend/admin/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
6085
frontend/admin/package-lock.json
generated
Normal file
6085
frontend/admin/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
frontend/admin/package.json
Normal file
57
frontend/admin/package.json
Normal 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
187
frontend/admin/src/App.vue
Normal 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>
|
||||
86
frontend/admin/src/assets/base.css
Normal file
86
frontend/admin/src/assets/base.css
Normal 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;
|
||||
}
|
||||
1
frontend/admin/src/assets/logo.svg
Normal file
1
frontend/admin/src/assets/logo.svg
Normal 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 |
76
frontend/admin/src/assets/main.css
Normal file
76
frontend/admin/src/assets/main.css
Normal 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;
|
||||
}
|
||||
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal file
48
frontend/admin/src/components/CronExpressionSelect.vue
Normal 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>
|
||||
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal file
104
frontend/admin/src/components/CronJobOrgUrlField.vue
Normal 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>
|
||||
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal file
52
frontend/admin/src/components/Form/CategoryLabel.vue
Normal 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>
|
||||
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal file
50
frontend/admin/src/components/Form/CategorySelect.vue
Normal 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>
|
||||
40
frontend/admin/src/components/Form/ResetCacheBtn.vue
Normal file
40
frontend/admin/src/components/Form/ResetCacheBtn.vue
Normal 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>
|
||||
70
frontend/admin/src/components/FormBuilder/CodeEditor.vue
Normal file
70
frontend/admin/src/components/FormBuilder/CodeEditor.vue
Normal 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>
|
||||
376
frontend/admin/src/components/FormBuilder/FieldSettings.vue
Normal file
376
frontend/admin/src/components/FormBuilder/FieldSettings.vue
Normal 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>
|
||||
54
frontend/admin/src/components/FormBuilder/FieldsPanel.vue
Normal file
54
frontend/admin/src/components/FormBuilder/FieldsPanel.vue
Normal 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>
|
||||
476
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal file
476
frontend/admin/src/components/FormBuilder/FormBuilder.vue
Normal 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>
|
||||
144
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal file
144
frontend/admin/src/components/FormBuilder/FormCanvas.vue
Normal 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>
|
||||
57
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal file
57
frontend/admin/src/components/FormBuilder/FormRenderer.vue
Normal 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>
|
||||
124
frontend/admin/src/components/FormBuilder/IconPicker.vue
Normal file
124
frontend/admin/src/components/FormBuilder/IconPicker.vue
Normal 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>
|
||||
|
||||
@@ -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, // Экспортируем метод установки ошибки
|
||||
};
|
||||
}
|
||||
@@ -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: '',
|
||||
}
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
191
frontend/admin/src/components/LogsViewer.vue
Normal file
191
frontend/admin/src/components/LogsViewer.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
54
frontend/admin/src/components/OcImagePicker.vue
Normal file
54
frontend/admin/src/components/OcImagePicker.vue
Normal 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>
|
||||
133
frontend/admin/src/components/RichTextEditor.vue
Normal file
133
frontend/admin/src/components/RichTextEditor.vue
Normal 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>
|
||||
|
||||
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal file
108
frontend/admin/src/components/ScheduledJobsList.vue
Normal 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>
|
||||
27
frontend/admin/src/components/Settings/ItemBool.vue
Normal file
27
frontend/admin/src/components/Settings/ItemBool.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<Switcher v-model="model"/>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Switcher from "@/components/Switcher.vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
116
frontend/admin/src/components/Settings/ItemCategoriesSelect.vue
Normal file
116
frontend/admin/src/components/Settings/ItemCategoriesSelect.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<input
|
||||
ref="searchInput"
|
||||
type="text"
|
||||
placeholder="Начните вводить название категории..."
|
||||
class="form-control"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="well well-sm tw:h-90 tw:overflow-auto">
|
||||
<div v-if="isLoading">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
Загрузка списка категорий...
|
||||
</div>
|
||||
<div v-else v-for="(product, index) in selectedProducts"
|
||||
class="tw:flex tw:items-center tw:mb-1">
|
||||
<button
|
||||
@click.prevent="removeItem(index)"
|
||||
class="btn btn-xs btn-danger"
|
||||
>
|
||||
<i class="fa fa-minus-circle"></i>
|
||||
</button>
|
||||
<div class="tw:ml-3">{{ product.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import {nextTick, onMounted, ref, watch} from "vue";
|
||||
import {apiPost} from "@/utils/http.js";
|
||||
|
||||
const searchInput = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
const model = defineModel();
|
||||
|
||||
function removeItem(index) {
|
||||
model.value.splice(index, 1);
|
||||
}
|
||||
|
||||
const selectedProducts = ref([]);
|
||||
watch(
|
||||
model.value,
|
||||
async (ids) => {
|
||||
if (!ids?.length) {
|
||||
selectedProducts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await apiPost('getCategoriesById', {
|
||||
category_ids: ids,
|
||||
});
|
||||
|
||||
selectedProducts.value = response.data.data;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (searchInput.value) {
|
||||
$(searchInput.value).autocomplete({
|
||||
source: function (request, response) {
|
||||
$.ajax({
|
||||
url: `/admin/index.php?route=catalog/category/autocomplete&user_token=${window.AcmeShop.user_token}&filter_name=${encodeURIComponent(request)}`,
|
||||
dataType: 'json',
|
||||
success: function (json) {
|
||||
response($.map(json, function (item) {
|
||||
return {
|
||||
label: item['name'],
|
||||
value: Number(item['category_id']),
|
||||
};
|
||||
}));
|
||||
}
|
||||
});
|
||||
},
|
||||
select: function (item) {
|
||||
model.value.push(item['value']);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
25
frontend/admin/src/components/Settings/ItemImage.vue
Normal file
25
frontend/admin/src/components/Settings/ItemImage.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<OcImagePicker v-model="model" class="tw:w-30"/>
|
||||
</template>
|
||||
<template #help><slot></slot></template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import OcImagePicker from "@/components/OcImagePicker.vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
72
frontend/admin/src/components/Settings/ItemInput.vue
Normal file
72
frontend/admin/src/components/Settings/ItemInput.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<InputGroup v-if="allowCopy && isSupported">
|
||||
<Button
|
||||
:key="copied ? 'copied' : 'copy'"
|
||||
:icon="copied ? 'fa fa-check' : 'fa fa-copy'"
|
||||
severity="secondary"
|
||||
v-tooltip.top="{ value: copied ? 'Скопировано' : 'Скопировать' }"
|
||||
@click="copyToClipboard"
|
||||
/>
|
||||
<InputText
|
||||
:type="type"
|
||||
v-model="model"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
<InputText
|
||||
v-else
|
||||
:type="type"
|
||||
v-model="model"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import InputText from 'primevue/inputtext';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import Button from 'primevue/button';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Введите значение'
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
allowCopy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
const model = defineModel();
|
||||
|
||||
const { copy, copied, isSupported } = useClipboard({ source: model })
|
||||
|
||||
function copyToClipboard() {
|
||||
copy();
|
||||
}
|
||||
</script>
|
||||
116
frontend/admin/src/components/Settings/ItemProductsSelect.vue
Normal file
116
frontend/admin/src/components/Settings/ItemProductsSelect.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<input
|
||||
ref="productsInput"
|
||||
type="text"
|
||||
placeholder="Начните вводить название товара..."
|
||||
class="form-control"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="well well-sm tw:h-90 tw:overflow-auto">
|
||||
<div v-if="isLoading">
|
||||
<i class="fa fa-spinner fa-spin"></i>
|
||||
Загрузка списка товаров...
|
||||
</div>
|
||||
<div v-else v-for="(product, index) in selectedProducts"
|
||||
class="tw:flex tw:items-center tw:mb-1">
|
||||
<button
|
||||
@click.prevent="removeItem(index)"
|
||||
class="btn btn-xs btn-danger"
|
||||
>
|
||||
<i class="fa fa-minus-circle"></i>
|
||||
</button>
|
||||
<div class="tw:ml-3">{{ product.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import {nextTick, onMounted, ref, watch} from "vue";
|
||||
import {apiPost} from "@/utils/http.js";
|
||||
|
||||
const productsInput = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
const model = defineModel();
|
||||
|
||||
function removeItem(index) {
|
||||
model.value.splice(index, 1);
|
||||
}
|
||||
|
||||
const selectedProducts = ref([]);
|
||||
watch(
|
||||
model.value,
|
||||
async (ids) => {
|
||||
if (!ids?.length) {
|
||||
selectedProducts.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading.value = true;
|
||||
const response = await apiPost('getProductsById', {
|
||||
product_ids: ids,
|
||||
});
|
||||
|
||||
selectedProducts.value = response.data.data;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
},
|
||||
{immediate: true}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
if (productsInput.value) {
|
||||
$(productsInput.value).autocomplete({
|
||||
source: function (request, response) {
|
||||
$.ajax({
|
||||
url: `/admin/index.php?route=catalog/product/autocomplete&user_token=${window.AcmeShop.user_token}&filter_name=${encodeURIComponent(request)}`,
|
||||
dataType: 'json',
|
||||
success: function (json) {
|
||||
response($.map(json, function (item) {
|
||||
return {
|
||||
label: item['name'],
|
||||
value: Number(item['product_id']),
|
||||
};
|
||||
}));
|
||||
}
|
||||
});
|
||||
},
|
||||
select: function (item) {
|
||||
model.value.push(item['value']);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
48
frontend/admin/src/components/Settings/ItemSelect.vue
Normal file
48
frontend/admin/src/components/Settings/ItemSelect.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<select class="form-control" v-model="model">
|
||||
<option
|
||||
v-for="(value, key) in items"
|
||||
:value="normalizeOptionValue(key)"
|
||||
:key="key"
|
||||
>
|
||||
{{ value }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot/>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
|
||||
const model = defineModel();
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Преобразуем числовые ключи обратно в Number, чтобы v-model не получал строки
|
||||
const normalizeOptionValue = (key) => {
|
||||
if (typeof key === 'number') {
|
||||
return key;
|
||||
}
|
||||
|
||||
const parsed = Number(key);
|
||||
return Number.isNaN(parsed) ? key : parsed;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
41
frontend/admin/src/components/Settings/ItemTextarea.vue
Normal file
41
frontend/admin/src/components/Settings/ItemTextarea.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<Textarea
|
||||
v-model="model"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:rows="rows"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import Textarea from 'primevue/textarea';
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
const model = defineModel();
|
||||
</script>
|
||||
145
frontend/admin/src/components/Settings/ItemTgBotToken.vue
Normal file
145
frontend/admin/src/components/Settings/ItemTgBotToken.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<div class="tw:flex tw:w-full">
|
||||
<span class="tw:flex">
|
||||
<button
|
||||
class="btn btn-primary tw:whitespace-nowrap"
|
||||
type="button"
|
||||
@click="validateBotToken"
|
||||
:disabled="isLoading || ! settings.items.telegram.bot_token"
|
||||
:class="{
|
||||
'tw:opacity-60 tw:cursor-not-allowed': isLoading
|
||||
}"
|
||||
>
|
||||
<i
|
||||
:class="isLoading ? 'fa fa-spinner fa-spin tw:mr-1' : 'fa fa-refresh tw:mr-1'"
|
||||
></i>
|
||||
{{ isLoading ? 'Проверяю...' : 'Проверить Bot Token' }}
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="model"
|
||||
@input="handleInput"
|
||||
@blur="validateBotToken"
|
||||
placeholder="Введите токен от Telegram бота"
|
||||
class="form-control"
|
||||
:readonly="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="validationStatus"
|
||||
class="alert"
|
||||
:class="validationStatusClass"
|
||||
>
|
||||
{{ validationStatus }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #help>
|
||||
Подробная инструкция доступна в
|
||||
<a href="https://acme-inc.github.io/docs/telegram/telegram/#%D1%81%D0%BE%D0%B7%D0%B4%D0%B0%D0%BD%D0%B8%D0%B5-%D0%B1%D0%BE%D1%82%D0%B0" target="_blank">документации
|
||||
<i class="fa fa-external-link"></i>
|
||||
</a>.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import {ref, computed} from "vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import {apiPost} from "@/utils/http.js";
|
||||
|
||||
const model = defineModel();
|
||||
const settings = useSettingsStore();
|
||||
const validationStatus = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const validationStatusClass = computed(() => {
|
||||
if (!validationStatus.value) return '';
|
||||
|
||||
if (validationStatus.value.startsWith('✅')) {
|
||||
return 'alert-success';
|
||||
}
|
||||
|
||||
if (validationStatus.value.startsWith('❌')) {
|
||||
return 'alert-danger';
|
||||
}
|
||||
|
||||
return 'alert-info';
|
||||
});
|
||||
|
||||
function handleInput(event) {
|
||||
model.value = event.target.value;
|
||||
// Сбрасываем статус валидации при изменении токена
|
||||
if (validationStatus.value) {
|
||||
validationStatus.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function validateBotToken() {
|
||||
const botToken = model.value?.trim() || '';
|
||||
|
||||
// Валидация пустого токена
|
||||
if (botToken.length === 0) {
|
||||
validationStatus.value = '❌ Введите Bot Token!';
|
||||
return;
|
||||
}
|
||||
|
||||
// Сбрасываем предыдущий статус
|
||||
validationStatus.value = null;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const result = await apiPost('configureBotToken', { botToken });
|
||||
|
||||
if (!result.success) {
|
||||
// Обработка ошибок
|
||||
if (result.status === 422) {
|
||||
validationStatus.value = `❌ Ошибка: ${result.error || 'Неверный токен'}`;
|
||||
} else {
|
||||
validationStatus.value = `❌ Ошибка проверки BotToken: ${result.error || 'Неизвестная ошибка'}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const response = result.data;
|
||||
|
||||
// Проверка наличия обязательных полей в ответе
|
||||
if (!response?.id) {
|
||||
validationStatus.value = '❌ Ошибка: bot token не найден в ответе сервера.';
|
||||
console.error('Неожиданный формат ответа:', response);
|
||||
return;
|
||||
}
|
||||
|
||||
// Успешная валидация
|
||||
const username = response.username ? `@${response.username}` : 'не указан';
|
||||
const webhookUrl = response.webhook_url || 'не настроен';
|
||||
validationStatus.value = `✅ Бот: ${username} (id: ${response.id}) webhook: ${webhookUrl}`;
|
||||
|
||||
// Обновляем токен в store, если нужно (на случай если сервер что-то изменил)
|
||||
if (response.bot_token && response.bot_token !== botToken) {
|
||||
model.value = response.bot_token;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при валидации BotToken:', error);
|
||||
validationStatus.value = '❌ Ошибка проверки BotToken. Проверьте подключение к серверу.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
190
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal file
190
frontend/admin/src/components/Settings/ItemTgChatID.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<template v-if="settings.items.telegram.bot_token">
|
||||
<div class="tw:flex tw:w-full">
|
||||
<span class="tw:flex">
|
||||
<button
|
||||
class="btn btn-primary tw:whitespace-nowrap"
|
||||
type="button"
|
||||
@click="getChatId"
|
||||
:disabled="isLoading || !settings.items.telegram.bot_token"
|
||||
:class="{
|
||||
'tw:opacity-60 tw:cursor-not-allowed': isLoading
|
||||
}"
|
||||
>
|
||||
<i
|
||||
:class="isLoading ? 'fa fa-spinner fa-spin tw:mr-1' : 'fa fa-refresh tw:mr-1'"
|
||||
></i>
|
||||
{{ isLoading ? 'Получаю...' : 'Получить Chat ID' }}
|
||||
</button>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
v-model="model"
|
||||
@input="handleInput"
|
||||
:placeholder="placeholder"
|
||||
class="form-control"
|
||||
:readonly="isLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
class="alert"
|
||||
:class="statusMessageClass"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-link btn-xs"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
:data-target="`#${collapseId}`"
|
||||
aria-expanded="false"
|
||||
:aria-controls="collapseId"
|
||||
>
|
||||
Инструкция как получить ChatID.
|
||||
</button>
|
||||
<div class="collapse" :id="collapseId">
|
||||
<div class="well">
|
||||
<p class="text-primary">Как получить Chat ID</p>
|
||||
<ol>
|
||||
<li>Убедитесь, что Telegram Bot Token введён выше.</li>
|
||||
<li>Откройте вашего бота в Telegram и отправьте ему кодовое слово: <code>ecommerce_get_chatid</code>. Важно отправить именно такое сообщение, иначе не сработает.</li>
|
||||
<li>Вернитесь сюда и нажмите кнопку «Получить Chat ID» — скрипт автоматически подставит его в поле ниже.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="alert alert-warning">
|
||||
<strong>BotToken</strong> не указан. Пожалуйста, введите корректный BotToken. После этого здесь станет доступна настройка ChatID.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #help>
|
||||
Идентификатор Telegram-чата, куда будут отправляться уведомления о новых заказах. Если оставить поле пустым, уведомления отправляться не будут.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import {ref, computed, useId} from "vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import {apiGet} from "@/utils/http.js";
|
||||
|
||||
const model = defineModel();
|
||||
const settings = useSettingsStore();
|
||||
const statusMessage = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const collapseId = useId();
|
||||
const parseChatId = (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const normalized = String(value).trim();
|
||||
if (!/^-?\d+$/.test(normalized)) return null;
|
||||
const parsed = Number.parseInt(normalized, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Chat ID будет получен автоматически',
|
||||
},
|
||||
});
|
||||
|
||||
if (typeof model.value === 'string') {
|
||||
model.value = parseChatId(model.value);
|
||||
}
|
||||
|
||||
const statusMessageClass = computed(() => {
|
||||
if (!statusMessage.value) return '';
|
||||
|
||||
if (statusMessage.value.startsWith('✅')) {
|
||||
return 'alert-success';
|
||||
}
|
||||
|
||||
if (statusMessage.value.startsWith('❌')) {
|
||||
return 'alert-danger';
|
||||
}
|
||||
|
||||
return 'alert-info';
|
||||
});
|
||||
|
||||
function handleInput(event) {
|
||||
model.value = parseChatId(event.target.value);
|
||||
// Сбрасываем статус сообщения при изменении значения
|
||||
if (statusMessage.value) {
|
||||
statusMessage.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getChatId() {
|
||||
// Проверка наличия bot_token
|
||||
if (!settings.items.telegram.bot_token?.trim()) {
|
||||
alert('Сначала введите Telegram Bot Token!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Сбрасываем предыдущее сообщение
|
||||
statusMessage.value = null;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const response = await apiGet('getChatId');
|
||||
|
||||
if (!response.success) {
|
||||
// Обработка ошибок
|
||||
const errorMessage = response.data?.message || response.error || 'Неизвестная ошибка';
|
||||
|
||||
if (response.status === 422) {
|
||||
statusMessage.value = `❌ ${errorMessage}`;
|
||||
} else {
|
||||
statusMessage.value = `❌ Ошибка получения Chat ID: ${errorMessage}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Проверка наличия chat_id в ответе
|
||||
if (!response.data?.chat_id) {
|
||||
statusMessage.value = '❌ Ошибка: Chat ID не найден в ответе сервера.';
|
||||
console.error('Неожиданный формат ответа:', response);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedChatId = parseChatId(response.data.chat_id);
|
||||
|
||||
if (parsedChatId === null) {
|
||||
statusMessage.value = '❌ Ошибка: Chat ID вернулся в некорректном формате.';
|
||||
console.error('Некорректный Chat ID в ответе:', response);
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = parsedChatId;
|
||||
statusMessage.value = '✅ ChatID успешно получен и подставлен в поле. Не забудьте сохранить настройки!';
|
||||
} catch (error) {
|
||||
console.error('Ошибка при получении Chat ID:', error);
|
||||
statusMessage.value = '❌ Ошибка получения Chat ID. Проверьте подключение к серверу.';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
|
||||
165
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal file
165
frontend/admin/src/components/Settings/ItemTgMessageTemplate.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<div style="margin-bottom: 10px;">
|
||||
<Codemirror
|
||||
v-model="model"
|
||||
:placeholder="placeholder"
|
||||
:extensions="extensions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
class="btn btn-link"
|
||||
type="button"
|
||||
data-toggle="collapse"
|
||||
:data-target="`#${collapseId}`"
|
||||
aria-expanded="false"
|
||||
:aria-controls="collapseId"
|
||||
>
|
||||
Документация
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="sendTestMessage"
|
||||
:disabled="isSending"
|
||||
:class="{
|
||||
'tw:opacity-60 tw:cursor-not-allowed': isSending
|
||||
}"
|
||||
>
|
||||
<i :class="isSending ? 'fa fa-spinner fa-spin' : 'fa fa-envelope'"></i>
|
||||
{{ isSending ? 'Отправляю...' : 'Отправить тестовое уведомление' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="collapse" :id="collapseId" style="margin-top: 15px">
|
||||
<div class="well">
|
||||
<p>
|
||||
Для формирования сообщения используется HTML разметка.
|
||||
Telegram поддерживает только часть HTML тегов, которые описаны в их
|
||||
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">документации <i class="fa fa-external-link"></i></a>.
|
||||
</p>
|
||||
<p>Дополнительно к этому AcmeShop добавляет переменные, которые вы можете использовать, чтобы сделать сообщения динамическими.</p>
|
||||
<ul>
|
||||
<li><code>{store_name}</code> — название магазина</li>
|
||||
<li><code>{order_id}</code> — номер заказа</li>
|
||||
<li><code>{customer}</code> — имя и фамилия покупателя</li>
|
||||
<li><code>{email}</code> — email покупателя</li>
|
||||
<li><code>{phone}</code> — телефон</li>
|
||||
<li><code>{comment}</code> — комментарий к заказу</li>
|
||||
<li><code>{address}</code> — адрес доставки</li>
|
||||
<li><code>{total}</code> — сумма заказа</li>
|
||||
<li><code>{ip}</code> — IP покупателя</li>
|
||||
<li><code>{created_at}</code> — дата и время создания заказа</li>
|
||||
</ul>
|
||||
<p></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #help>
|
||||
<slot></slot>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import {ref, toRaw, useId} from "vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import {apiPost} from "@/utils/http.js";
|
||||
import {Codemirror} from "vue-codemirror";
|
||||
import { html } from '@codemirror/lang-html';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
|
||||
const model = defineModel();
|
||||
const settings = useSettingsStore();
|
||||
const isSending = ref(false);
|
||||
const collapseId = useId();
|
||||
const extensions = [html(), oneDark];
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Введите шаблон сообщения',
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
});
|
||||
|
||||
async function sendTestMessage() {
|
||||
console.log(toRaw(settings.items.telegram));
|
||||
const telegramToken = settings.items.telegram.bot_token?.trim();
|
||||
|
||||
if (!telegramToken) {
|
||||
alert('Сначала введите Telegram Bot Token!');
|
||||
return;
|
||||
}
|
||||
|
||||
const chatId = settings.items.telegram.chat_id;
|
||||
|
||||
if (!chatId) {
|
||||
alert('Сначала введите Chat ID!');
|
||||
return;
|
||||
}
|
||||
|
||||
const template = model.value?.trim();
|
||||
|
||||
if (!template) {
|
||||
alert('Сначала задайте шаблон!');
|
||||
return;
|
||||
}
|
||||
|
||||
isSending.value = true;
|
||||
|
||||
try {
|
||||
const result = await apiPost('testTgMessage', {
|
||||
token: telegramToken,
|
||||
chat_id: chatId,
|
||||
template: template,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
const errorMessage = result.data?.message || result.error || 'Неизвестная ошибка';
|
||||
alert(`Ошибка: ${errorMessage}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = result.data;
|
||||
alert(response.message || 'Уведомление успешно отправлено');
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отправке тестового сообщения:', error);
|
||||
alert('Ошибка при отправке тестового сообщения');
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 10px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
31
frontend/admin/src/components/Settings/ItemTgMiniAppLink.vue
Normal file
31
frontend/admin/src/components/Settings/ItemTgMiniAppLink.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<ItemInput
|
||||
:label="label"
|
||||
type="text"
|
||||
:readonly="true"
|
||||
:modelValue="model"
|
||||
:allowCopy="true"
|
||||
>
|
||||
Ссылка на сайт с AcmeShop витриной, которую нужно указывать в настройках MiniApp в @BotFather.<br>
|
||||
Подробная инструкция по настройке в
|
||||
<a href="https://docs.acmeshop.pro/telegram/telegram/" target="_blank">
|
||||
документации <i class="fa fa-external-link"></i>
|
||||
</a>.
|
||||
</ItemInput>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
const model = defineModel();
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
51
frontend/admin/src/components/Settings/ItemToggleButton.vue
Normal file
51
frontend/admin/src/components/Settings/ItemToggleButton.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<SettingsItem :label="label">
|
||||
<template #default>
|
||||
<SelectButton
|
||||
:modelValue="model"
|
||||
:options="options"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
@update:modelValue="updateValue"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
<slot/>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {computed} from "vue";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
import SelectButton from "primevue/selectbutton";
|
||||
|
||||
const model = defineModel();
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Object,
|
||||
default: {},
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
return Object.entries(props.items).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
});
|
||||
|
||||
function updateValue(newValue) {
|
||||
model.value = newValue;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
|
||||
67
frontend/admin/src/components/SettingsItem.vue
Normal file
67
frontend/admin/src/components/SettingsItem.vue
Normal 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>
|
||||
66
frontend/admin/src/components/Slider/CategorySelect.vue
Normal file
66
frontend/admin/src/components/Slider/CategorySelect.vue
Normal 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>
|
||||
59
frontend/admin/src/components/Slider/LinkSelector.vue
Normal file
59
frontend/admin/src/components/Slider/LinkSelector.vue
Normal 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>
|
||||
65
frontend/admin/src/components/Slider/ProductSelect.vue
Normal file
65
frontend/admin/src/components/Slider/ProductSelect.vue
Normal 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>
|
||||
15
frontend/admin/src/components/Switcher.vue
Normal file
15
frontend/admin/src/components/Switcher.vue
Normal 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>
|
||||
229
frontend/admin/src/components/TopLead.vue
Normal file
229
frontend/admin/src/components/TopLead.vue
Normal 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>
|
||||
24
frontend/admin/src/formkit.config.js
Normal file
24
frontend/admin/src/formkit.config.js
Normal 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;
|
||||
3398
frontend/admin/src/formkit.theme.mjs
Normal file
3398
frontend/admin/src/formkit.theme.mjs
Normal file
File diff suppressed because it is too large
Load Diff
90
frontend/admin/src/main.js
Normal file
90
frontend/admin/src/main.js
Normal 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();
|
||||
});
|
||||
31
frontend/admin/src/router/index.js
Normal file
31
frontend/admin/src/router/index.js
Normal 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;
|
||||
19
frontend/admin/src/stores/autocomplete.js
Normal file
19
frontend/admin/src/stores/autocomplete.js
Normal 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;
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
12
frontend/admin/src/stores/counter.js
Normal file
12
frontend/admin/src/stores/counter.js
Normal 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 }
|
||||
})
|
||||
14
frontend/admin/src/stores/forms.js
Normal file
14
frontend/admin/src/stores/forms.js
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
21
frontend/admin/src/stores/logs.js
Normal file
21
frontend/admin/src/stores/logs.js
Normal 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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
175
frontend/admin/src/stores/settings.js
Normal file
175
frontend/admin/src/stores/settings.js
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
22
frontend/admin/src/stores/stats.js
Normal file
22
frontend/admin/src/stores/stats.js
Normal 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;
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
7
frontend/admin/src/utils/constants..js
Normal file
7
frontend/admin/src/utils/constants..js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const sliderEffectOptions = {
|
||||
slide: 'Слайд',
|
||||
flip: 'Переворот',
|
||||
cards: 'Карточки',
|
||||
cube: 'Куб',
|
||||
coverflow: 'Перекрывающиеся слайды',
|
||||
};
|
||||
17
frontend/admin/src/utils/helpers.js
Normal file
17
frontend/admin/src/utils/helpers.js
Normal 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);
|
||||
}
|
||||
142
frontend/admin/src/utils/http.js
Normal file
142
frontend/admin/src/utils/http.js
Normal 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,
|
||||
};
|
||||
|
||||
2
frontend/admin/src/utils/toastHelper.js
Normal file
2
frontend/admin/src/utils/toastHelper.js
Normal file
@@ -0,0 +1,2 @@
|
||||
import mitt from 'mitt';
|
||||
export const toastBus = mitt();
|
||||
180
frontend/admin/src/views/AcmeShopPulseView.vue
Normal file
180
frontend/admin/src/views/AcmeShopPulseView.vue
Normal file
@@ -0,0 +1,180 @@
|
||||
<template>
|
||||
<div class="tw:space-y-6">
|
||||
<div class="acmeshop-pulse-info">
|
||||
<h3>🚀 Расширьте возможности вашего магазина с <strong><a href="https://acmeshop.pro/" target="_blank">AcmeShop Pulse</a>!</strong></h3>
|
||||
|
||||
<p>
|
||||
Если вы хотите не только показывать товары в Telegram, но и активно общаться с клиентами,
|
||||
рассылать новости, акции и уведомления — для этого есть <strong>AcmeShop Pulse</strong>.
|
||||
Это <strong>SaaS-платформа с месячной подпиской</strong>, которая полностью интегрируется
|
||||
с вашим ECommerce-магазином и витриной AcmeShop.
|
||||
</p>
|
||||
|
||||
<p><strong>С AcmeShop Pulse вы сможете:</strong></p>
|
||||
|
||||
<ul>
|
||||
<li>📣 Делать массовые рассылки сообщений покупателям прямо в Telegram</li>
|
||||
<li>📊 Анализировать эффективность сообщений и взаимодействие клиентов</li>
|
||||
<li>🔗 Легко синхронизироваться с вашей витриной AcmeShop — все данные остаются в одном месте</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
🧪 Платформа <strong>AcmeShop Pulse находится на ранней стадии тестирования</strong>.
|
||||
Если вам интересно и вы хотите принять участи в тестировании интересно, свяжитесь со мной через
|
||||
<a href="https://t.me/ocstore3" target="_blank">официальную группу AcmeShop в Telegram</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SettingsItem v-if="settings.items.pulse.api_key" label="Статистика за 7 дней">
|
||||
<template #default>
|
||||
<div v-if="stats" class="tw:space-y-4">
|
||||
<div class="tw:flex tw:gap-3 tw:max-w-2xl">
|
||||
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
|
||||
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
|
||||
<div class="tw:text-xs tw:font-medium tw:text-gray-700">В очереди</div>
|
||||
<div
|
||||
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-yellow-400 tw:to-yellow-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
|
||||
<i class="fa fa-clock-o tw:text-white tw:text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
|
||||
stats.pending
|
||||
}}
|
||||
</div>
|
||||
<div class="tw:text-xs tw:text-gray-500">Ожидают отправки</div>
|
||||
</div>
|
||||
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
|
||||
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
|
||||
<div class="tw:text-xs tw:font-medium tw:text-gray-700">Отправлено</div>
|
||||
<div
|
||||
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-green-400 tw:to-green-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
|
||||
<i class="fa fa-check-circle tw:text-white tw:text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
|
||||
stats.sent
|
||||
}}
|
||||
</div>
|
||||
<div class="tw:text-xs tw:text-gray-500">Успешно доставлено</div>
|
||||
</div>
|
||||
<div class="tw:group tw:bg-white tw:rounded-lg tw:shadow tw:p-3 tw:relative tw:flex-1 tw:transition-all tw:duration-200 tw:cursor-default tw:hover:shadow-md tw:hover:-translate-y-0.5">
|
||||
<div class="tw:flex tw:justify-between tw:items-start tw:mb-1.5">
|
||||
<div class="tw:text-xs tw:font-medium tw:text-gray-700">Ошибки</div>
|
||||
<div
|
||||
class="tw:w-8 tw:h-8 tw:rounded-lg tw:bg-gradient-to-br tw:from-red-400 tw:to-red-600 tw:flex tw:items-center tw:justify-center tw:transition-transform tw:duration-200 tw:group-hover:scale-110">
|
||||
<i class="fa fa-exclamation-circle tw:text-white tw:text-xs"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw:text-3xl tw:font-bold tw:text-gray-800 tw:mb-0.5">{{
|
||||
stats.failed
|
||||
}}
|
||||
</div>
|
||||
<div class="tw:text-xs tw:text-gray-500">Требуют внимания</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
Статистика обновляется 1 раз в час
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<ItemInput label="API ключ"
|
||||
v-model="settings.items.pulse.api_key"
|
||||
placeholder="AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
|
||||
>
|
||||
Используется для обмена информацией по кампаниям, рассылкам, сбору метрик.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Размер пакета обработки"
|
||||
v-model.number="settings.items.pulse.batch_size"
|
||||
type="number"
|
||||
placeholder="50"
|
||||
>
|
||||
Определяет, сколько событий отправляется в AcmeShop Pulse за один запуск фоновой задачи.
|
||||
При большом значении события обрабатываются быстрее, но увеличивается нагрузка на сервер.
|
||||
При малом значении нагрузка ниже, но обработка занимает больше времени.
|
||||
Рекомендуемое значение: 50.
|
||||
</ItemInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, ref} from "vue";
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import {apiGet} from "@/utils/http.js";
|
||||
import SettingsItem from "@/components/SettingsItem.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const stats = ref(null);
|
||||
|
||||
const loadStats = async () => {
|
||||
const response = await apiGet('getAcmeShopPulseStats');
|
||||
if (response.success) {
|
||||
stats.value = response.data;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadStats();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.acmeshop-pulse-info {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #212529;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info h3 a {
|
||||
color: #dc3545;
|
||||
font-weight: 700;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info h3 a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info p {
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.6;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info p strong {
|
||||
color: #212529;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info ul {
|
||||
margin: 16px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info ul li {
|
||||
color: #495057;
|
||||
position: relative;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.acmeshop-pulse-info ul li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
147
frontend/admin/src/views/CronView.vue
Normal file
147
frontend/admin/src/views/CronView.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<SettingsItem label="Режим работы планировщика" doc-href="https://docs.acmeshop.pro/features/cron/">
|
||||
<template #default>
|
||||
<SelectButton
|
||||
v-model="settings.items.cron.mode"
|
||||
:options="cronModes"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="false"
|
||||
/>
|
||||
</template>
|
||||
<template #help>
|
||||
<div v-if="settings.items.cron.mode === 'disabled'" class="tw:text-red-600 tw:font-bold">
|
||||
Все фоновые задачи отключены.
|
||||
</div>
|
||||
<div v-else-if="settings.items.cron.mode === 'cron_job_org'">
|
||||
Задачи запускаются по вызову URL с сервиса <a href="https://cron-job.org/" target="_blank" rel="noopener" class="tw:underline">cron-job.org</a>. Подходит для лёгких задач; при большом количестве товаров или тяжёлых операциях возможны таймауты.
|
||||
</div>
|
||||
<div v-else>
|
||||
Рекомендуемый режим. Использует системный планировщик задач Linux.
|
||||
</div>
|
||||
</template>
|
||||
<template #expandable>
|
||||
<p>
|
||||
<strong>Системный CRON (рекомендуется):</strong> Задачи выполняются через команду PHP в оболочке сервера (CLI), без HTTP и без ограничений по времени запроса. Не зависит от посещаемости сайта и подходит для любых объёмов данных, в том числе для тяжёлых задач и больших каталогов. Требует доступа к серверу (SSH или панель с CRON). Добавьте команду ниже в планировщик (обычно <code class="tw:px-1 tw:py-0.5 tw:bg-gray-100 tw:dark:bg-gray-800 tw:rounded">crontab -e</code>) для запуска каждые 5 минут.
|
||||
</p>
|
||||
<p>
|
||||
<strong>cron-job.org:</strong> Внешний сервис по расписанию вызывает URL вашего сайта по HTTP. Не требует доступа к серверу — удобно для shared-хостинга без CRON. Ограничения: выполнение идёт через веб-запрос, поэтому есть лимиты по времени (timeout у хостинга и у cron-job.org). <strong>Не подходит для тяжёлых сайтов</strong> (много товаров, большие каталоги, тяжёлые задачи): запрос может обрываться по таймауту, задачи не успеют завершиться. Выбирайте этот способ только если нет доступа к системному CRON и нагрузка на планировщик небольшая.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Выключено:</strong> Все фоновые задачи отключены. Планировщик не будет выполнять никаких задач.
|
||||
</p>
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<div class="tw:relative tw:mt-4">
|
||||
<div
|
||||
:class="[
|
||||
'tw:transition-all tw:duration-200',
|
||||
settings.items.cron.mode === 'disabled'
|
||||
? 'tw:blur-[2px] tw:pointer-events-none tw:select-none'
|
||||
: '',
|
||||
]"
|
||||
>
|
||||
<SettingsItem label="Последний запуск CRON">
|
||||
<template #default>
|
||||
<div v-if="lastRunDate" class="tw:text-green-600 tw:font-bold tw:py-2">
|
||||
{{ lastRunDate }}
|
||||
</div>
|
||||
<div v-else class="tw:text-gray-500 tw:py-2">
|
||||
Еще не запускался
|
||||
</div>
|
||||
</template>
|
||||
<template #help>
|
||||
Время последнего успешного выполнения планировщика задач.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem
|
||||
v-if="settings.items.cron.mode === 'system'"
|
||||
label="Команда для CRON"
|
||||
>
|
||||
<template #default>
|
||||
<InputGroup>
|
||||
<Button icon="fa fa-copy" severity="secondary" @click="copyToClipboard(cronCommand)"/>
|
||||
<InputText readonly :model-value="cronCommand" class="tw:w-full"/>
|
||||
</InputGroup>
|
||||
</template>
|
||||
<template #help>
|
||||
Добавьте эту строку в конфигурацию CRON на вашем сервере (обычно `crontab -e`), чтобы запускать планировщик каждые
|
||||
5 минут.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
|
||||
<CronJobOrgUrlField v-if="settings.items.cron.mode === 'cron_job_org'"/>
|
||||
|
||||
<SettingsItem label="Задачи планировщика">
|
||||
<template #default>
|
||||
<ScheduledJobsList />
|
||||
</template>
|
||||
<template #help>
|
||||
Включение и отключение задач планировщика. Дата последнего успешного запуска; при ошибке отображается иконка с подсказкой.
|
||||
</template>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="settings.items.cron.mode === 'disabled'"
|
||||
class="tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:rounded-lg tw:bg-white/80 tw:dark:bg-gray-900/80 tw:z-10"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="tw:text-lg tw:font-semibold tw:text-gray-600 tw:dark:text-gray-400">
|
||||
Планировщик выключен
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useSettingsStore } from '@/stores/settings.js';
|
||||
import SettingsItem from '@/components/SettingsItem.vue';
|
||||
import ScheduledJobsList from '@/components/ScheduledJobsList.vue';
|
||||
import CronJobOrgUrlField from '@/components/CronJobOrgUrlField.vue';
|
||||
import SelectButton from 'primevue/selectbutton';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import InputGroup from 'primevue/inputgroup';
|
||||
import { toastBus } from '@/utils/toastHelper.js';
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const cronModes = [
|
||||
{value: 'system', label: 'Системный CRON (Linux)'},
|
||||
{value: 'cron_job_org', label: 'cron-job.org'},
|
||||
{value: 'disabled', label: 'Выключено'},
|
||||
];
|
||||
|
||||
const cronCommand = computed(() => {
|
||||
const cliPath = settings.items.cron?.cli_path;
|
||||
|
||||
return cliPath
|
||||
? `*/5 * * * * php ${cliPath} schedule:run`
|
||||
: 'Путь не определен. Проверьте конфигурацию модуля.';
|
||||
});
|
||||
|
||||
const lastRunDate = computed(() => settings.items.cron?.last_run);
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toastBus.emit('show', {
|
||||
severity: 'success',
|
||||
summary: 'Скопировано',
|
||||
detail: 'Команда скопирована в буфер обмена',
|
||||
life: 2000,
|
||||
});
|
||||
} catch (err) {
|
||||
toastBus.emit('show', {
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось скопировать текст',
|
||||
life: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
706
frontend/admin/src/views/CustomersView.vue
Normal file
706
frontend/admin/src/views/CustomersView.vue
Normal file
@@ -0,0 +1,706 @@
|
||||
<template>
|
||||
<div>
|
||||
<DataTable
|
||||
:value="customers"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="20"
|
||||
:rowsPerPageOptions="[10, 20, 50, 100]"
|
||||
:sortField="lazyParams.sortField"
|
||||
:sortOrder="lazyParams.sortOrder"
|
||||
showGridlines
|
||||
stripedRows
|
||||
size="small"
|
||||
removableSort
|
||||
:globalFilterFields="['telegram_user_id', 'username', 'first_name', 'last_name', 'language_code']"
|
||||
v-model:filters="filters"
|
||||
filterDisplay="menu"
|
||||
:lazy="true"
|
||||
:totalRecords="totalRecords"
|
||||
@page="onPage"
|
||||
@sort="onSort"
|
||||
@filter="onFilter"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
:currentPageReportTemplate="`Показано {first} - {last} из {totalRecords} записей`"
|
||||
>
|
||||
<template #header>
|
||||
<div class="tw:flex tw:flex-wrap tw:items-center tw:justify-between tw:gap-2">
|
||||
<div class="tw:flex tw:items-center tw:gap-2">
|
||||
<Button
|
||||
icon="fa fa-columns"
|
||||
:label="`Колонки (${selectedColumns.length}/${columns.length})`"
|
||||
@click="toggleColumnsPanel"
|
||||
size="small"
|
||||
/>
|
||||
<OverlayPanel ref="columnsPanel">
|
||||
<div class="tw:flex tw:flex-col tw:gap-2 tw:min-w-[200px]">
|
||||
<div class="tw:flex tw:gap-2 tw:mb-2">
|
||||
<Button
|
||||
label="Выбрать все"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="selectAllColumns"
|
||||
class="tw:flex-1"
|
||||
/>
|
||||
<Button
|
||||
label="Снять все"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="deselectAllColumns"
|
||||
class="tw:flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="col in columns"
|
||||
:key="col.field"
|
||||
class="tw:flex tw:items-center tw:gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
:inputId="col.field"
|
||||
:modelValue="selectedColumns.some(c => c.field === col.field)"
|
||||
@update:modelValue="(val) => toggleColumn(col, val)"
|
||||
:binary="true"
|
||||
/>
|
||||
<label :for="col.field" class="tw:cursor-pointer">{{ col.header }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayPanel>
|
||||
<Button icon="fa fa-refresh" @click="loadCustomers" v-tooltip.top="'Обновить таблицу'"
|
||||
size="small"/>
|
||||
<Button icon="fa fa-times-circle" label="Сбросить фильтры" @click="resetFilters"
|
||||
v-tooltip.top="'Сбросить все фильтры'" size="small"/>
|
||||
</div>
|
||||
|
||||
<IconField>
|
||||
<InputIcon class="fa fa-search"/>
|
||||
<InputText v-model="globalSearchValue" placeholder="Поиск по таблице..."
|
||||
@input="onGlobalSearch"/>
|
||||
</IconField>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<Column header="Действия" :exportable="false" headerStyle="width: 5rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="fa fa-paper-plane"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="openMessageDialog(data)"
|
||||
v-tooltip.top="'Отправить сообщение пользователю в Telegram'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column v-for="col in selectedColumns" :key="col.field" :field="col.field"
|
||||
:sortable="col.sortable" :dataType="col.dataType"
|
||||
:showFilterMenu="col.filterable !== false">
|
||||
<template #header>
|
||||
<div class="tw:flex tw:items-center tw:gap-1">
|
||||
<span>{{ col.header }}</span>
|
||||
<i
|
||||
v-if="col.help"
|
||||
class="fa fa-question-circle tw:text-gray-400 tw:cursor-help"
|
||||
v-tooltip.top="col.help"
|
||||
></i>
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ data }">
|
||||
<template v-if="col.field === 'id'">{{ data.id }}</template>
|
||||
<template v-else-if="col.field === 'telegram_user_id'">
|
||||
{{ data.telegram_user_id }}
|
||||
</template>
|
||||
<template v-else-if="col.field === 'tracking_id'">
|
||||
<code>{{ data.tracking_id }}</code>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'username'">
|
||||
<div class="tw:flex tw:items-center tw:gap-2">
|
||||
<div v-if="data.photo_url" class="tw:relative">
|
||||
<img
|
||||
:src="data.photo_url"
|
||||
:alt="data.username || 'Avatar'"
|
||||
class="tw:w-6 tw:h-6 tw:rounded-full tw:object-cover"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
<i v-else class="fa fa-user tw:text-gray-400"></i>
|
||||
<span v-if="data.username">@{{ data.username }}</span>
|
||||
<span v-else>—</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'first_name'">{{ data.first_name || '—' }}</template>
|
||||
<template v-else-if="col.field === 'last_name'">{{ data.last_name || '—' }}</template>
|
||||
<template v-else-if="col.field === 'language_code'">
|
||||
<span v-if="data.language_code">
|
||||
<i class="fa fa-globe"></i> {{ data.language_code.toUpperCase() }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'is_premium'">
|
||||
<i v-if="data.is_premium" class="fa fa-star" v-tooltip.top="'Премиум пользователь'"></i>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'orders_count'">
|
||||
<span>{{ data.orders_count }}</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'oc_customer_id'">
|
||||
<span v-if="data.oc_customer_id">{{ data.oc_customer_id }}</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'last_seen_at'">
|
||||
<span v-if="data.last_seen_at">
|
||||
<i class="fa fa-clock-o"></i> {{ formatDate(data.last_seen_at) }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'privacy_consented_at'">
|
||||
<span v-if="data.privacy_consented_at">
|
||||
<i class="fa fa-clock-o"></i> {{ formatDate(data.privacy_consented_at) }}
|
||||
</span>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'created_at'">
|
||||
<i class="fa fa-calendar"></i> {{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<template v-if="col.field === 'telegram_user_id'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по ID"
|
||||
class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'username'">
|
||||
<InputText v-model="filterModel.value" type="text"
|
||||
placeholder="Поиск по имени пользователя" class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'first_name'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по имени"
|
||||
class="p-column-filter"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'last_name'">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Поиск по фамилии"
|
||||
class="p-column-filter"/>
|
||||
</template>
|
||||
<template
|
||||
v-else-if="['last_seen_at', 'created_at', 'privacy_consented_at'].includes(col.field)">
|
||||
<DatePicker v-model="filterModel.value" dateFormat="dd.mm.yy" placeholder="dd.mm.yyyy"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'orders_count'">
|
||||
<InputNumber v-model="filterModel.value"/>
|
||||
</template>
|
||||
<template v-else-if="col.field === 'is_premium'">
|
||||
<Dropdown
|
||||
v-model="filterModel.value"
|
||||
:options="premiumFilterOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Любой"
|
||||
class="p-column-filter"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fa fa-users" style="font-size: 3rem; color: #ccc; margin-bottom: 1rem;"></i>
|
||||
<div>Нет данных о кастомерах</div>
|
||||
<div style="font-size: 0.9rem; color: #999; margin-top: 0.5rem;">
|
||||
Пользователи появятся здесь после первого входа в Telegram Mini App
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #loading>
|
||||
<div style="text-align: center; padding: 2rem;">
|
||||
<i class="fa fa-spinner fa-spin" style="font-size: 2rem;"></i>
|
||||
<div style="margin-top: 1rem;">Загрузка данных...</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showMessageDialog"
|
||||
modal
|
||||
header="Отправить сообщение"
|
||||
:style="{ width: '500px' }"
|
||||
:closable="true"
|
||||
>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div style="margin-bottom: 0.5rem; font-weight: 600;">
|
||||
Получатель:
|
||||
</div>
|
||||
<div v-if="selectedCustomer">
|
||||
<div v-if="selectedCustomer.username">
|
||||
<i class="fa fa-user"></i> @{{ selectedCustomer.username }}
|
||||
</div>
|
||||
<div v-if="selectedCustomer.first_name || selectedCustomer.last_name">
|
||||
{{ selectedCustomer.first_name }} {{ selectedCustomer.last_name }}
|
||||
</div>
|
||||
<div style="font-size: 0.9rem; color: #666;">
|
||||
ID: {{ selectedCustomer.telegram_user_id }}
|
||||
</div>
|
||||
<div v-if="!selectedCustomer.allows_write_to_pm"
|
||||
style="color: #f59e0b; margin-top: 0.5rem;">
|
||||
<i class="fa fa-exclamation-triangle"></i> Пользователь не разрешил писать ему в PM
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="message" style="display: block; margin-bottom: 0.5rem; font-weight: 600;">
|
||||
Сообщение:
|
||||
</label>
|
||||
<Textarea
|
||||
id="message"
|
||||
v-model="messageText"
|
||||
:rows="5"
|
||||
:disabled="!selectedCustomer || !selectedCustomer.allows_write_to_pm"
|
||||
placeholder="Введите текст сообщения..."
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Отмена"
|
||||
icon="fa fa-times"
|
||||
severity="secondary"
|
||||
@click="closeMessageDialog"
|
||||
:disabled="sendingMessage"
|
||||
/>
|
||||
<Button
|
||||
label="Отправить"
|
||||
icon="fa fa-paper-plane"
|
||||
@click="sendMessage"
|
||||
:loading="sendingMessage"
|
||||
:disabled="!messageText || !selectedCustomer || !selectedCustomer.allows_write_to_pm"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {onMounted, ref} from 'vue';
|
||||
import {FilterMatchMode, FilterOperator} from '@primevue/core/api';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import DatePicker from 'primevue/datepicker';
|
||||
import Dropdown from 'primevue/dropdown';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import OverlayPanel from 'primevue/overlaypanel';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Button from 'primevue/button';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import {apiPost} from '@/utils/http.js';
|
||||
import {IconField, InputIcon, useToast} from 'primevue';
|
||||
|
||||
const toast = useToast();
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
const totalRecords = ref(0);
|
||||
const showMessageDialog = ref(false);
|
||||
const selectedCustomer = ref(null);
|
||||
const messageText = ref('');
|
||||
const sendingMessage = ref(false);
|
||||
|
||||
const columns = ref([
|
||||
{field: 'id', header: '№', sortable: true, filterable: false, visible: true},
|
||||
{
|
||||
field: 'telegram_user_id',
|
||||
header: 'ID в Telegram',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
field: 'tracking_id',
|
||||
header: 'Tracking ID',
|
||||
sortable: false,
|
||||
filterable: true,
|
||||
visible: false,
|
||||
help: 'Tracking ID это публичный уникальный идентификатор покупателя, используется в рекламных кампаниях для отслеживания активности.',
|
||||
},
|
||||
{field: 'username', header: 'Имя пользователя', sortable: true, filterable: true, visible: true},
|
||||
{field: 'first_name', header: 'Имя', sortable: true, filterable: true, visible: true},
|
||||
{field: 'last_name', header: 'Фамилия', sortable: true, filterable: true, visible: true},
|
||||
{
|
||||
field: 'language_code',
|
||||
header: 'Язык интерфейса',
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
field: 'orders_count',
|
||||
header: 'Кол-во заказов',
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
dataType: 'numeric',
|
||||
visible: true,
|
||||
help: 'Общее количество Telegram заказов за всё время',
|
||||
},
|
||||
{field: 'is_premium', header: 'Премиум статус', sortable: true, filterable: true, visible: true},
|
||||
{
|
||||
field: 'oc_customer_id',
|
||||
header: 'ID покупателя',
|
||||
sortable: true,
|
||||
filterable: false,
|
||||
visible: false
|
||||
},
|
||||
{
|
||||
field: 'last_seen_at',
|
||||
header: 'Последний визит',
|
||||
sortable: true,
|
||||
dataType: 'date',
|
||||
filterable: true,
|
||||
visible: true,
|
||||
help: 'Показывает, когда пользователь последний раз открывал Telegram магазин.',
|
||||
},
|
||||
{
|
||||
field: 'privacy_consented_at',
|
||||
header: 'Согласие ПД',
|
||||
sortable: true,
|
||||
dataType: 'date',
|
||||
filterable: true,
|
||||
visible: true,
|
||||
help: 'Показывает, когда пользователь дал согласие на обработку персональных данных.',
|
||||
},
|
||||
{
|
||||
field: 'created_at',
|
||||
header: 'Дата регистрации',
|
||||
sortable: true,
|
||||
dataType: 'date',
|
||||
filterable: true,
|
||||
visible: true,
|
||||
help: 'Показывает, когда пользователь впервые открыл Telegram магазин.',
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedColumns = ref(columns.value.filter(col => col.visible !== false));
|
||||
const columnsPanel = ref(null);
|
||||
const globalSearchValue = ref('');
|
||||
let searchTimeout = null;
|
||||
|
||||
function toggleColumnsPanel(event) {
|
||||
columnsPanel.value.toggle(event);
|
||||
}
|
||||
|
||||
function toggleColumn(col, checked) {
|
||||
// Сохраняем порядок колонок из исходного массива columns
|
||||
const selectedFields = new Set(selectedColumns.value.map(c => c.field));
|
||||
|
||||
if (checked) {
|
||||
selectedFields.add(col.field);
|
||||
} else {
|
||||
selectedFields.delete(col.field);
|
||||
}
|
||||
|
||||
// Пересоздаем массив, сохраняя исходный порядок из columns
|
||||
selectedColumns.value = columns.value.filter(c => selectedFields.has(c.field));
|
||||
}
|
||||
|
||||
function selectAllColumns() {
|
||||
selectedColumns.value = [...columns.value];
|
||||
}
|
||||
|
||||
function deselectAllColumns() {
|
||||
selectedColumns.value = [];
|
||||
}
|
||||
|
||||
const premiumFilterOptions = [
|
||||
{label: 'Любой', value: null},
|
||||
{label: 'Нет', value: false},
|
||||
{label: 'Да', value: true},
|
||||
];
|
||||
|
||||
const lazyParams = ref({
|
||||
first: 0,
|
||||
rows: 20,
|
||||
page: 1,
|
||||
sortField: 'last_seen_at',
|
||||
sortOrder: -1,
|
||||
filters: {},
|
||||
});
|
||||
|
||||
const filters = ref({
|
||||
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
|
||||
telegram_user_id: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
|
||||
},
|
||||
username: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
first_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
last_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
orders_count: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}],
|
||||
},
|
||||
is_premium: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
},
|
||||
created_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
last_seen_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
privacy_consented_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
});
|
||||
|
||||
function processFiltersForBackend(filtersObj) {
|
||||
const processed = JSON.parse(JSON.stringify(filtersObj));
|
||||
|
||||
// Обрабатываем фильтры по датам
|
||||
const dateFields = ['created_at', 'last_seen_at', 'privacy_consented_at'];
|
||||
dateFields.forEach(field => {
|
||||
if (processed[field] && processed[field].constraints) {
|
||||
processed[field].constraints.forEach(constraint => {
|
||||
if (constraint.value && ['dateIs', 'dateIsNot', 'dateBefore', 'dateAfter'].includes(constraint.matchMode)) {
|
||||
// Преобразуем дату в формат YYYY-MM-DD, используя локальное время
|
||||
const date = new Date(constraint.value);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
constraint.value = `${year}-${month}-${day}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
async function loadCustomers(event = null) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const processedFilters = processFiltersForBackend(filters.value);
|
||||
|
||||
const params = {
|
||||
page: lazyParams.value.page,
|
||||
rows: lazyParams.value.rows,
|
||||
sortField: lazyParams.value.sortField,
|
||||
sortOrder: lazyParams.value.sortOrder === -1 ? 'DESC' : 'ASC',
|
||||
filters: processedFilters,
|
||||
};
|
||||
|
||||
const result = await apiPost('getTelegramCustomers', params);
|
||||
if (result.success && result.data) {
|
||||
// apiPost возвращает полный ответ сервера, а apiGet возвращает response.data.data
|
||||
// Поэтому здесь нужно проверить, есть ли вложенный data
|
||||
const responseData = result.data.data ? result.data.data : result.data;
|
||||
customers.value = Array.isArray(responseData) ? responseData : (responseData.data || []);
|
||||
totalRecords.value = responseData.totalRecords || 0;
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: result.error || 'Не удалось загрузить данные',
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при загрузке кастомеров:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Произошла ошибка при загрузке данных',
|
||||
life: 3000,
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onPage(event) {
|
||||
lazyParams.value.first = event.first;
|
||||
lazyParams.value.rows = event.rows;
|
||||
lazyParams.value.page = event.page + 1;
|
||||
loadCustomers(event);
|
||||
}
|
||||
|
||||
function onSort(event) {
|
||||
lazyParams.value.sortField = event.sortField;
|
||||
lazyParams.value.sortOrder = event.sortOrder;
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
loadCustomers(event);
|
||||
}
|
||||
|
||||
function onFilter(event) {
|
||||
filters.value = event.filters;
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
loadCustomers(event);
|
||||
}
|
||||
|
||||
function onGlobalSearch() {
|
||||
// Обновляем глобальный фильтр
|
||||
filters.value.global.value = globalSearchValue.value || null;
|
||||
|
||||
// Сбрасываем на первую страницу
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
|
||||
// Debounce: очищаем предыдущий таймер
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout);
|
||||
}
|
||||
|
||||
// Устанавливаем новый таймер для отправки запроса через 300ms после последнего ввода
|
||||
searchTimeout = setTimeout(() => {
|
||||
loadCustomers();
|
||||
}, 800);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
globalSearchValue.value = '';
|
||||
filters.value = {
|
||||
global: {value: null, matchMode: FilterMatchMode.CONTAINS},
|
||||
telegram_user_id: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.STARTS_WITH}]
|
||||
},
|
||||
username: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
first_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
last_name: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.CONTAINS}]
|
||||
},
|
||||
orders_count: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
},
|
||||
is_premium: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.EQUALS}]
|
||||
},
|
||||
created_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
last_seen_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
privacy_consented_at: {
|
||||
operator: FilterOperator.AND,
|
||||
constraints: [{value: null, matchMode: FilterMatchMode.DATE_IS}]
|
||||
},
|
||||
};
|
||||
lazyParams.value.page = 1;
|
||||
lazyParams.value.first = 0;
|
||||
loadCustomers();
|
||||
}
|
||||
|
||||
function handleImageError(event) {
|
||||
// Скрываем изображение при ошибке загрузки
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '—';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function openMessageDialog(customer) {
|
||||
selectedCustomer.value = customer;
|
||||
messageText.value = '';
|
||||
showMessageDialog.value = true;
|
||||
}
|
||||
|
||||
function closeMessageDialog() {
|
||||
showMessageDialog.value = false;
|
||||
selectedCustomer.value = null;
|
||||
messageText.value = '';
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!selectedCustomer.value || !messageText.value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedCustomer.value.allows_write_to_pm) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Предупреждение',
|
||||
detail: 'Пользователь не разрешил писать ему в PM',
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
sendingMessage.value = true;
|
||||
try {
|
||||
const result = await apiPost('sendMessageToCustomer', {
|
||||
id: selectedCustomer.value.id,
|
||||
message: messageText.value.trim(),
|
||||
});
|
||||
|
||||
if (result.success && result.data?.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Успешно',
|
||||
detail: result.data?.message || 'Сообщение отправлено',
|
||||
life: 3000,
|
||||
});
|
||||
closeMessageDialog();
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: result.data?.error || result.error || 'Не удалось отправить сообщение',
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Ошибка при отправке сообщения:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Произошла ошибка при отправке сообщения',
|
||||
life: 3000,
|
||||
});
|
||||
} finally {
|
||||
sendingMessage.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
23
frontend/admin/src/views/FormBuilderView.vue
Normal file
23
frontend/admin/src/views/FormBuilderView.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div v-if="isLoading" class="tw:flex tw:justify-center tw:items-center tw:h-full">
|
||||
<i class="fa fa-spinner fa-spin tw:text-4xl tw:text-blue-500"></i>
|
||||
</div>
|
||||
<div v-else class="tw:h-full">
|
||||
<FormBuilder
|
||||
v-model="settings.items.forms.checkout.schema"
|
||||
v-model:isCustom="settings.items.forms.checkout.is_custom"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {ref} from 'vue';
|
||||
import FormBuilder from '@/components/FormBuilder/FormBuilder.vue';
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
|
||||
const formSchema = ref([]);
|
||||
const isCustom = ref(false);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const settings = useSettingsStore();
|
||||
</script>
|
||||
97
frontend/admin/src/views/GeneralView.vue
Normal file
97
frontend/admin/src/views/GeneralView.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<ItemBool label="Статус" v-model="settings.items.app.app_enabled">
|
||||
Если выключено, покупатели в Telegram увидят сообщение, что магазин временно закрыт.
|
||||
Заказы и просмотр товаров будут недоступны.
|
||||
</ItemBool>
|
||||
|
||||
<ItemInput label="Название приложения"
|
||||
v-model="settings.items.app.app_name"
|
||||
placeholder="Без названия"
|
||||
>
|
||||
Отображается в заголовке Telegram Mini App при запуске, а также используется как подпись
|
||||
под иконкой, если пользователь добавит приложение на главный экран своего устройства.
|
||||
Рекомендуется короткое и понятное название (до 20 символов).
|
||||
Если оставить пустым, то название выводиться не будет.
|
||||
</ItemInput>
|
||||
|
||||
<ItemImage label="Иконка приложения" v-model="settings.items.app.app_icon">
|
||||
Изображение, которое будет отображаться в Telegram Mini App.
|
||||
</ItemImage>
|
||||
|
||||
<ItemInput label="Политика конфиденциальности"
|
||||
v-model="settings.items.app.privacy_policy_link"
|
||||
placeholder="https://your-site/privacy-polisy"
|
||||
>
|
||||
Укажите ссылку на страницу с вашей Политикой конфиденциальности.
|
||||
Она будет показываться пользователям для получения согласия на обработку персональных данных.
|
||||
Вы сможете видеть, кто согласился, на странице с
|
||||
<RouterLink to="/customers">Telegram покупателями</RouterLink>
|
||||
.
|
||||
</ItemInput>
|
||||
|
||||
<ItemSelect label="Светлая тема" v-model="settings.items.app.theme_light" :items="themes">
|
||||
Выберите стиль, который будет использоваться при отображении вашего магазина
|
||||
в Telegram для дневного режима.
|
||||
<a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">
|
||||
Посмотреть как выглядят темы
|
||||
</a>
|
||||
</ItemSelect>
|
||||
|
||||
<ItemSelect label="Тёмная тема" v-model="settings.items.app.theme_dark" :items="themes">
|
||||
Выберите стиль, который будет использоваться при отображении вашего магазина
|
||||
в Telegram для ночного режима.
|
||||
<a href="https://daisyui.com/docs/themes/#list-of-themes" target="_blank">
|
||||
Посмотреть как выглядят темы
|
||||
</a>
|
||||
</ItemSelect>
|
||||
|
||||
<ItemBool label="Режим разработчика" v-model="settings.items.app.app_debug">
|
||||
Режим разработчика. Рекомендуется включать только по необходимости.
|
||||
В остальных случаях, для нормальной работы магазина, должен быть выключен.
|
||||
</ItemBool>
|
||||
|
||||
<ItemSelect label="Соотношение сторон" v-model="settings.items.app.image_aspect_ratio" :items="aspectRatioOptions">
|
||||
Выберите соотношение сторон для изображений товаров. Это глобальная настройка, которая будет применяться ко всем изображениям в списках товаров: карусель товаров, лента товаров, результаты поиска.
|
||||
</ItemSelect>
|
||||
|
||||
<ItemSelect label="Алгоритм обрезки" v-model="settings.items.app.image_crop_algorithm" :items="cropAlgorithmOptions">
|
||||
Выберите алгоритм обрезки изображений. Эта настройка применяется глобально ко всем изображениям в списках товаров (карусель товаров, лента товаров, результаты поиска):
|
||||
<ul class="tw:list-disc tw:ml-5 tw:mt-2">
|
||||
<li><strong>Cover</strong> - обрезает изображение, сохраняя пропорции, чтобы заполнить весь размер (может обрезать края)</li>
|
||||
<li><strong>Contain</strong> - вписывает изображение в размер, сохраняя пропорции (может добавить пустые поля)</li>
|
||||
<li><strong>Resize</strong> - изменяет размер изображения с сохранением пропорций (без обрезки)</li>
|
||||
</ul>
|
||||
</ItemSelect>
|
||||
|
||||
<ItemBool label="Тактильная обратная связь (Haptic Feedback)" v-model="settings.items.app.haptic_enabled">
|
||||
Включить виброотклик при взаимодействии с элементами интерфейса. Если выключено, тактильная обратная связь не будет использоваться.
|
||||
</ItemBool>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemBool from "@/components/Settings/ItemBool.vue";
|
||||
import ItemImage from "@/components/Settings/ItemImage.vue";
|
||||
import ItemSelect from "@/components/Settings/ItemSelect.vue";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const themes = JSON.parse(window.AcmeShop.themes);
|
||||
|
||||
const aspectRatioOptions = {
|
||||
'1:1': '1:1 - Квадрат (универсально, аксессуары, мелкие товары)',
|
||||
'4:5': '4:5 - Вертикальное (одежда, обувь, вертикальные товары)',
|
||||
'3:4': '3:4 - Вертикальное (одежда, обувь, вертикальные товары)',
|
||||
'2:3': '2:3 - Высокое вертикальное (цветы, высокие предметы)',
|
||||
};
|
||||
|
||||
const cropAlgorithmOptions = {
|
||||
'cover': 'Cover - Обрезать с сохранением пропорций',
|
||||
'contain': 'Contain - Вписать с сохранением пропорций',
|
||||
'resize': 'Resize - Изменить размер с сохранением пропорций',
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
8
frontend/admin/src/views/LogsView.vue
Normal file
8
frontend/admin/src/views/LogsView.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<LogsViewer/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import LogsViewer from "@/components/LogsViewer.vue";
|
||||
</script>
|
||||
7
frontend/admin/src/views/MainPageView.vue
Normal file
7
frontend/admin/src/views/MainPageView.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<MainPageConfigurator/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MainPageConfigurator from "@/components/MainPageConfigurator/MainPageConfigurator.vue";
|
||||
</script>
|
||||
53
frontend/admin/src/views/MetricsView.vue
Normal file
53
frontend/admin/src/views/MetricsView.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<ItemBool
|
||||
label="Яндекс.Метрика"
|
||||
v-model="settings.items.metrics.yandex_metrika_enabled"
|
||||
>
|
||||
Задействовать Яндекс.Метрику для Telegram магазина.
|
||||
</ItemBool>
|
||||
|
||||
<ItemInput
|
||||
label="Номер счётчика Яндекс.Метрика"
|
||||
v-model="settings.items.metrics.yandex_metrika_counter"
|
||||
placeholder="Вставьте код счётчика Яндекс.Метрики"
|
||||
>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
v-if="settings.items.metrics.yandex_metrika_enabled && settings.items.metrics.yandex_metrika_counter"
|
||||
as="a"
|
||||
:href="ymCheckUrl"
|
||||
target="_blank"
|
||||
variant="text"
|
||||
:disabled="settings.items.app.app_debug === false"
|
||||
v-tooltip.top="'Чтобы проверить интеграцию, включите режим разработчика на вкладке Общие и сохраните настройки.'"
|
||||
>
|
||||
Проверить интеграцию
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
as="a"
|
||||
href="https://acme-inc.github.io/docs/analitycs/start/"
|
||||
target="_blank"
|
||||
variant="text"
|
||||
>
|
||||
Как получить номер счётчика <i class="fa fa-external-link"></i>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
</ItemInput>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemBool from "@/components/Settings/ItemBool.vue";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import {Button, ButtonGroup} from 'primevue';
|
||||
import {computed} from "vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const ymCheckUrl = computed(() => {
|
||||
const url = settings.items.telegram.mini_app_url.replace(/#\/$/, '');
|
||||
return `${url}?_ym_status-check=${settings.items.metrics.yandex_metrika_counter}&_ym_lang=ru`;
|
||||
});
|
||||
</script>
|
||||
17
frontend/admin/src/views/OrdersView.vue
Normal file
17
frontend/admin/src/views/OrdersView.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<ItemSelect
|
||||
label="Статус заказов"
|
||||
v-model="settings.items.orders.order_default_status_id"
|
||||
:items="orderStatuses"
|
||||
>
|
||||
Статус, с которым будут создаваться заказы через Telegram по умолчанию.
|
||||
</ItemSelect>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemSelect from "@/components/Settings/ItemSelect.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
const orderStatuses = JSON.parse(window.AcmeShop.order_statuses);
|
||||
</script>
|
||||
65
frontend/admin/src/views/StoreView.vue
Normal file
65
frontend/admin/src/views/StoreView.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<ItemToggleButton
|
||||
label="Сценарий взаимодействия с товаром"
|
||||
v-model="settings.items.store.product_interaction_mode"
|
||||
:items="productInteractionOptions"
|
||||
>
|
||||
<p>Выберите, что будет происходить при нажатии на кнопку товара:
|
||||
<br><strong>Создание заявки / заказа</strong> — Пользователи смогут добавить товар и оформить заявку на покупку прямо в Telegram. Заказ фиксируется в ECommerce, а дальнейшая работа с клиентом происходит вручную.
|
||||
<br><strong>Кнопка связи с менеджером</strong> — пользователи увидят кнопку для связи с менеджером в Telegram. Менеджера можно указать в поле "Username менеджера" ниже.
|
||||
<br><strong>Открытие товара на сайте</strong> — кнопка откроет страницу товара на основном сайте ECommerce во внешнем браузере.</p>
|
||||
</ItemToggleButton>
|
||||
|
||||
<ItemInput
|
||||
label="Username менеджера"
|
||||
v-model="settings.items.store.manager_username"
|
||||
placeholder="@username"
|
||||
>
|
||||
<p>Укажите username (например, @username) для связи с менеджером. Это может быть личный аккаунт или группа, куда покупатели могут писать. Используется только при выборе режима "Кнопка связи с менеджером".</p>
|
||||
</ItemInput>
|
||||
|
||||
<ItemBool label="Промокоды" v-model="settings.items.store.feature_coupons">
|
||||
<p>
|
||||
Позволяет использовать стандартные
|
||||
<a :href="`/admin/index.php?route=marketing/coupon&user_token=${userToken}`"
|
||||
target="_blank">купоны ECommerce</a>
|
||||
для предоставления скидок при оформлении заказа.</p>
|
||||
</ItemBool>
|
||||
|
||||
<ItemBool label="Подарочные сертификаты" v-model="settings.items.store.feature_vouchers">
|
||||
<p>
|
||||
Позволяет использовать стандартные
|
||||
<a :href="`/admin/index.php?route=sale/voucher&user_token=${userToken}`"
|
||||
target="_blank">подарочные сертификаты ECommerce</a> при оформлении заказа.</p>
|
||||
</ItemBool>
|
||||
|
||||
<ItemBool label="Показывать кнопку «Показать товары из текущей категории»" v-model="settings.items.store.show_category_products_button">
|
||||
<p>Включите, чтобы пользователи видели кнопку «Показать товары из "название текущей категории"» на странице категории, если у неё есть дочерние категории. Настройка работает только для страниц категорий с дочерними категориями, при отключении кнопка скрыта.</p>
|
||||
</ItemBool>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemBool from "@/components/Settings/ItemBool.vue";
|
||||
import ItemSelect from "@/components/Settings/ItemSelect.vue";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import ItemToggleButton from "@/components/Settings/ItemToggleButton.vue";
|
||||
import ItemProductsSelect from "@/components/Settings/ItemProductsSelect.vue";
|
||||
import ItemCategoriesSelect from "@/components/Settings/ItemCategoriesSelect.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
|
||||
const mainpage_categories_options = {
|
||||
no_categories: 'Отображать только кнопку "Каталог"',
|
||||
latest10: 'Последние 10 категорий',
|
||||
featured: 'Избранные категории (задать в поле ниже)',
|
||||
};
|
||||
|
||||
const productInteractionOptions = {
|
||||
order: 'Создание заявки / заказа',
|
||||
manager: 'Кнопка связи с менеджером',
|
||||
browser: 'Открытие товара на сайте',
|
||||
};
|
||||
|
||||
const userToken = window.AcmeShop.user_token;
|
||||
</script>
|
||||
28
frontend/admin/src/views/TelegramView.vue
Normal file
28
frontend/admin/src/views/TelegramView.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<ItemTgMiniAppLink label="Ссылка на Telegram Mini App"
|
||||
v-model="settings.items.telegram.mini_app_url"/>
|
||||
<ItemTgBotToken label="Telegram Bot Token" v-model="settings.items.telegram.bot_token"/>
|
||||
<ItemTgChatID label="Telegram ChatID" v-model="settings.items.telegram.chat_id"/>
|
||||
<ItemTgMessageTemplate
|
||||
label="Шаблон уведомления о новом заказе владельцу"
|
||||
v-model="settings.items.telegram.owner_notification_template"
|
||||
>
|
||||
Введите шаблон сообщения для Telegram-уведомлений о новом заказе владельцу магазина.
|
||||
</ItemTgMessageTemplate>
|
||||
<ItemTgMessageTemplate
|
||||
label="Шаблон уведомления о новом заказе покупателю"
|
||||
v-model="settings.items.telegram.customer_notification_template"
|
||||
>
|
||||
Введите шаблон сообщения для Telegram-уведомлений о новом заказе покупателю.
|
||||
</ItemTgMessageTemplate>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemTgMiniAppLink from "@/components/Settings/ItemTgMiniAppLink.vue";
|
||||
import ItemTgBotToken from "@/components/Settings/ItemTgBotToken.vue";
|
||||
import ItemTgChatID from "@/components/Settings/ItemTgChatID.vue";
|
||||
import ItemTgMessageTemplate from "@/components/Settings/ItemTgMessageTemplate.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
</script>
|
||||
46
frontend/admin/src/views/TextsView.vue
Normal file
46
frontend/admin/src/views/TextsView.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<ItemInput label="Текст в конце списка товаров" v-model="settings.items.texts.text_no_more_products">
|
||||
Текст, отображаемый в конце списка, когда больше нет доступных товаров.
|
||||
Покупатель дошел до конца списка.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст пустой корзины" v-model="settings.items.texts.text_empty_cart">
|
||||
Текст, отображаемый на странице просмотра корзины, если в ней нет товаров.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст для успешного заказа" v-model="settings.items.texts.text_order_created_success">
|
||||
Текст, отображаемый при успешном создании заказа.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст вместо нулевой цены" v-model="settings.items.texts.zero_price_text" placeholder="0.00р.">
|
||||
Текст, который будет выводиться вместо цены, в случае если цена = 0.
|
||||
Если текст отсутствует, то будет выводиться нулевая цена по умолчанию.
|
||||
</ItemInput>
|
||||
|
||||
<ItemTextarea label="Приветственный текст" v-model="settings.items.texts.start_message" placeholder="Например, добро пожаловать в наш магазин.">
|
||||
Сообщение, которое выводится в приветственном сообщении покупателю (когда он
|
||||
запустит бота через `/start`). Можно использовать HTML разметку, которую
|
||||
<a href="https://core.telegram.org/bots/api#html-style" target="_blank">
|
||||
поддерживает Telegram <i class="fa fa-external-link"></i>
|
||||
</a>. Можно использовать <a href="https://getemoji.com/" target="_blank">
|
||||
эмодзи <i class="fa fa-external-link"></i>
|
||||
</a>.
|
||||
</ItemTextarea>
|
||||
|
||||
<ItemInput label="Текст кнопки приветственного сообщения" v-model="settings.items.texts.start_button.text">
|
||||
Текст на кнопке приветственного сообщения, которая открывает магазин.
|
||||
</ItemInput>
|
||||
|
||||
<ItemInput label="Текст кнопки связи с менеджером" v-model="settings.items.texts.text_manager_button" placeholder="Связаться с менеджером">
|
||||
Текст на кнопке для связи с менеджером на странице товара. Используется только при выборе режима "Кнопка связи с менеджером" в настройках витрины.
|
||||
</ItemInput>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {useSettingsStore} from "@/stores/settings.js";
|
||||
import ItemInput from "@/components/Settings/ItemInput.vue";
|
||||
import ItemImage from "@/components/Settings/ItemImage.vue";
|
||||
import ItemTextarea from "@/components/Settings/ItemTextarea.vue";
|
||||
|
||||
const settings = useSettingsStore();
|
||||
</script>
|
||||
9
frontend/admin/tailwind.config.js
Normal file
9
frontend/admin/tailwind.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
prefix: 'tw:',
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
'./templates/**/*.twig',
|
||||
'./views/**/*.php',
|
||||
],
|
||||
};
|
||||
48
frontend/admin/vite.config.js
Normal file
48
frontend/admin/vite.config.js
Normal 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
15
frontend/spa/index.html
Normal 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
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
45
frontend/spa/package.json
Normal 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
158
frontend/spa/src/App.vue
Normal 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>
|
||||
7
frontend/spa/src/AppLoading.vue
Normal file
7
frontend/spa/src/AppLoading.vue
Normal 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
Reference in New Issue
Block a user