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
|
||||
}
|
||||
44
frontend/admin/README.md
Normal file
44
frontend/admin/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# admin
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
bun install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
bun dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
bun run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
bun lint
|
||||
```
|
||||
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"
|
||||
}
|
||||
}
|
||||
BIN
frontend/admin/public/favicon.ico
Normal file
BIN
frontend/admin/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
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();
|
||||
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>
|
||||
180
frontend/admin/src/views/TeleCartPulseView.vue
Normal file
180
frontend/admin/src/views/TeleCartPulseView.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>
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
5
frontend/spa/README.md
Normal file
5
frontend/spa/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Vue 3 + Vite
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
|
||||
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user