mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-11-13 23:25:06 +00:00
release: AVE.cms 3.3 build 0.43
Publish the verified clean core runtime with editor relation, paste and highlighting fixes, session maintenance and consistent database backups. No private storefront, commerce or AI modules are included.
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
/tmp/
|
||||
/uploads/
|
||||
/storage/backups/
|
||||
/storage/database-restore/
|
||||
/storage/importprice/
|
||||
/storage/imports/
|
||||
/storage/jobs/
|
||||
|
||||
@@ -11,6 +11,90 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
## 3.3 build 0.43 — 13 ноября 2026
|
||||
|
||||
### Ядро, панель и отдельные модули
|
||||
|
||||
- CLI-помощник выпуска самостоятельно подключает административные сервисы:
|
||||
запуск сборки не зависит от предварительного открытия панели.
|
||||
|
||||
- Исправлена потеря множественных связей «Документы из рубрики (выбор)»
|
||||
при сохранении: числовые списки с разделителем `|` больше не читаются как
|
||||
одна запись «название|ID». Сохраняются все выбранные документы и их порядок.
|
||||
В полях «HTML / код» восстановлена подсветка HTML через режим `htmlmixed`.
|
||||
|
||||
- В визуальном редакторе исправлены лишние пустые абзацы при вставке Word
|
||||
и копировании между полями: удаляются отступы HTML между блоками и пустые
|
||||
блоки по краям фрагмента. Пустые абзацы внутри текста, пробелы между
|
||||
выделениями, списки и форматирование кода сохраняются. Кнопка Word использует
|
||||
тот же механизм вставки, что и буфер обмена. Показ блоков включён по умолчанию
|
||||
и отключается кнопкой в каждом редакторе, включая компактный профиль.
|
||||
|
||||
- Исправлено создание полных бэкапов работающего сайта: отдельный согласованный
|
||||
снимок InnoDB и потоковое чтение таблиц вместо порций с OFFSET. Записи,
|
||||
добавляемые во время копирования, больше не сдвигают порции и не создают
|
||||
повторы первичных ключей в дампе; транзакция вызывающего кода не затрагивается.
|
||||
|
||||
- В «Настройки → Обслуживание» добавлена кнопка очистки просроченных публичных
|
||||
сессий: подтверждение, последовательные порции, прогресс и остановка.
|
||||
Видны отдельные счётчики просроченных и действующих записей, доступность
|
||||
фонового запуска и результат последней автоматической попытки с ошибкой.
|
||||
Исправлено переполнение страницы обслуживания на узких экранах.
|
||||
|
||||
- Витрина МедМос: отдельная статья-справочник об ИПРА с самостоятельным текстом,
|
||||
официальными источниками, оглавлением, этапами оформления, сравнением способов
|
||||
получения ТСР, FAQ, отмечаемой памяткой и печатью. Нативный документ и
|
||||
дополнительный шаблон, редактируемые поля и необязательная фотография;
|
||||
безопасный установочный файл для переноса без замены общих шаблонов сайта.
|
||||
|
||||
- Публичные сессии БД очищаются небольшими индексированными порциями:
|
||||
до 1000 просроченных записей в минуту, без параллельных проходов.
|
||||
На PHP-FPM уборка выполняется после отправки ответа; в Планировщике 1.2.1
|
||||
добавлена отдельная задача для cron. Действующие и продлённые сессии
|
||||
сохраняются, удаление всей таблицы и автоматический OPTIMIZE не выполняются.
|
||||
|
||||
- Импорт цен 1.5.4: на вкладке «Новые и выпавшие» состояние карточки на сайте
|
||||
выделено отдельным бейджем, выключенные отмечены серым с иконкой скрытого
|
||||
просмотра. Добавлены счётчики состояния текущей выборки, понятный фильтр
|
||||
«Состояние на сайте» и кнопка «Обновить список»; CSV сохраняет этот статус.
|
||||
|
||||
- Импорт цен 1.5.3: вкладка «Новые и выпавшие» показывает оба типа изменений
|
||||
ассортимента. Новые позиции отмечены зелёным, отсутствующие — оранжевым;
|
||||
добавлены отдельные счётчики и выбор «все / новые / выпавшие». CSV учитывает
|
||||
фильтр, сохранённый результат старого формата пересчитывается автоматически.
|
||||
|
||||
- Импорт цен 1.5.2: отдельная вкладка «Выпавшие товары» с фильтрами,
|
||||
переходом в карточку и CSV. При загрузке нового XLSX или получении по ссылке
|
||||
текущий прайс сохраняется как предыдущий, сравнение выполняется сразу,
|
||||
количество отсутствующих позиций видно в модуле. Повторная загрузка того
|
||||
же файла сохраняет базу сравнения; товары и цены автоматически не меняются.
|
||||
|
||||
- Витрина МедМос: текст поля «Акция (подарок)» теперь показан отдельным
|
||||
заметным блоком под ценой на странице товара. Сохраняются переносы строк;
|
||||
пустое поле не создаёт плашку. Расчёт акций и выдача подарков не менялись.
|
||||
|
||||
- Импорт цен 1.5.1: форма создания черновика подключена ко всем готовым
|
||||
разборам семейств товаров, включая кислородные концентраторы и насосы.
|
||||
Вместо 11 доступно 37 пунктов, автоматический выбор учитывает нативный
|
||||
набор раздела. В форме видны его название и ссылки на настройку;
|
||||
неоднозначные исполнения не подставляются из первого заголовка прайса.
|
||||
|
||||
- Аналитика магазина 0.9.0: отчёт «Контакты по категориям» с отдельными
|
||||
списками заказов и брошенных корзин, выбором периода и нескольких категорий,
|
||||
подкатегориями, контактами, фильтрами отмен/оплаты и обработки корзин.
|
||||
Достаточно одной подходящей позиции; снимки состава и суммы не пересчитываются.
|
||||
Добавлены сохранённые наборы фильтров и две выгрузки XLSX: одна запись на строку,
|
||||
многострочные товары/категории в ячейках, телефон текстом и лист параметров.
|
||||
|
||||
- Импорт цен 1.5.0: разбор гинекологических кресел в отдельный нативный набор,
|
||||
создание черновика прямо из строки XLSX, отдельные комплектация и опции.
|
||||
В расхождениях выделены новые и исчезнувшие артикулы, сохранён независимый
|
||||
фильтр состояния товара на сайте, добавлены подсказки о смене артикула и CSV.
|
||||
|
||||
- Товары 1.13.13: сохранение определения характеристики и фильтров раздела
|
||||
больше не подтверждает чужую транзакцию; вложенные операции используют
|
||||
точки сохранения и корректно откатываются при ошибке.
|
||||
|
||||
- Исправлен единый результат оформления заказа: первый запрос и его повтор
|
||||
получают одинаковый сохранённый снимок для оплаты и чека.
|
||||
|
||||
|
||||
+34
-26
@@ -2,20 +2,20 @@
|
||||
"format": "ave-core-package-v2",
|
||||
"product": "AVE.cms",
|
||||
"version": "3.3",
|
||||
"build": "0.42",
|
||||
"build": "0.43",
|
||||
"package": "core",
|
||||
"built_at": "2026-10-13T15:01:23+00:00",
|
||||
"built_at": "2026-11-13T09:19:40+00:00",
|
||||
"installed_modules": 0,
|
||||
"bundled_optional_modules": [],
|
||||
"optional_after_install": [
|
||||
"setup/"
|
||||
],
|
||||
"core_fingerprint": "9717e3250c604abd868d4149bce909b3df72fcc5e72b840dd1fe6aa238d527ab",
|
||||
"core_fingerprint": "48efe9890af77388f2753e437cdffc3076036c1eb599e3d7a34c16abf5e199b3",
|
||||
"files": {
|
||||
".gitignore": "20d4fb177d47da6944a7a1c2dbf5918fd62766881aa52b00901f9054b1db4b49",
|
||||
".gitignore": "cfa0d7a40b308397e43404c00e6c7b9260dcb11401e384028e9d183c0bfd3243",
|
||||
".htaccess": "8f891b9bf25d536848f169a539d0a42baf5e9cd9599321542598874476ca3ad3",
|
||||
"CHANGELOG.md": "4595b4f99447da7ff5915807bc2aa6320edcf42594d195a2f0203cbfb2f224a9",
|
||||
"README.md": "0ef45b0e6da8dc25c4d64bbaaa2163ee5078302888e4515b75e60f827902665a",
|
||||
"CHANGELOG.md": "fbebe5b600dc7f7c6cbe40930318fd9cb3eb46fe3596c7d32d6079f57bf92615",
|
||||
"README.md": "d56bc75ab2b24af56541b2031595030456464f8f629d27b820a305de5f37ebe8",
|
||||
"adminx/.htaccess": "d03ea2d2972af8b7545a2d57c6253f303d15adeb30b775e161d887ca5ff73378",
|
||||
"adminx/Support/AdminLocale.php": "a7fe84d86d65c9f021106172b1789c8bc6221c36b2fe39cf883ca9a505e3e8ad",
|
||||
"adminx/Support/BulkActionExecutor.php": "82859d10eec39a53091ed46604d6819e7111b7d879e5f0f1595c26f8a7ff1a60",
|
||||
@@ -50,17 +50,17 @@
|
||||
"adminx/assets/js/adminx.js": "0f2efdf3ba50652f1e979bfc546d562e38e44e13f2bfb7c42f666e8b5d944a17",
|
||||
"adminx/assets/js/bulk-actions.js": "d7ee80678e6703aff2418a4679ba8e5027e7220cb089848bcced6d9f100b3aae",
|
||||
"adminx/assets/js/command-palette.js": "f62f0bad5ddf2b6e813e7eda094d73df63dce09a6e581025971e8f10430e8d9f",
|
||||
"adminx/assets/js/editor-codemirror.js": "90a04c219e84f5391a40b0d1fc807ecfe4ca582a198bc202bdc5aa6dccec51fa",
|
||||
"adminx/assets/js/editor-tiptap.js": "c3e79ed4fb4635b2bbdaebfa8ec8680c3fe1b64f3dee48354669d3baa5a29b07",
|
||||
"adminx/assets/js/editor-codemirror.js": "e22942662ea93adc017fc4be181eda3d19af27edc3a2291938cb544c90cbfba3",
|
||||
"adminx/assets/js/editor-tiptap.js": "964d73321630d667d181f0f72f1900cec594e5c50fa9064fec6e1e050c28a6e1",
|
||||
"adminx/assets/js/media-picker.js": "74481279a7d352c7c72893b4043e02454ff3cc28b6582c5463dfa712b17e23a2",
|
||||
"adminx/assets/js/saved-views.js": "30c02121634f6f035dcff6a730e6fe300a1892fc9321971f142dcff600802f30",
|
||||
"adminx/assets/src/_rich-extensions.js": "bf6cdd7537852f271aa79f7f56a733d1bbb18b7340c3a81bfdb101cd56fbdaa9",
|
||||
"adminx/assets/src/_rich-recovery.js": "b9cae994ab21debae2d1194e1f48018aa1067401617aaac15800a58264a06bbb",
|
||||
"adminx/assets/src/_rich-tools.js": "01674cafdd98ed8b718fd0719d2b5e5eb681da7dd9dc2d18b57ccb35948d5e5f",
|
||||
"adminx/assets/src/_word-paste.js": "06671edab949ba84c8599cd25f4fc21923db3d2a1e98a02e6ed22d0b8610c816",
|
||||
"adminx/assets/src/_word-paste.js": "97cc9105da5987d301d97d0698793cf4d2d95e381f0dace413157c66d050ff64",
|
||||
"adminx/assets/src/bulk-actions.js": "29bcc568366ce4a86a71b12a0f8be050bd71a9eabb0f65af6d9ca57281db9201",
|
||||
"adminx/assets/src/command-palette.js": "ff86b0dc0f1ad59d551af772643dbe094cdfb2367d402eca49fd1a4c5b1bb9db",
|
||||
"adminx/assets/src/editor-tiptap.js": "19a42915a34a5efcce867da59eeb442c15599713d1c1c4b15d85a64a21c4489f",
|
||||
"adminx/assets/src/editor-tiptap.js": "404d4152e52a4fc4bbb5c1f58572b741a35987a081f4156001bfc5c1b52b9c4e",
|
||||
"adminx/assets/vendor/codemirror/addon/dialog/dialog.css": "5df690d771f12118348562dd2a90060433ae2edeccd0908abcaa4480b1e3e468",
|
||||
"adminx/assets/vendor/codemirror/addon/dialog/dialog.js": "1eea0e500dce10d8594d8d681a91d1b71a4209355c9c29885e86e26b2d76685e",
|
||||
"adminx/assets/vendor/codemirror/addon/edit/closetag.js": "700b8aa7c46e040287e32ffd1edb86a42d09edc5b2a9594241590b154c0232bd",
|
||||
@@ -276,7 +276,7 @@
|
||||
"adminx/modules/Dashboard/view/widgets/recent-documents.twig": "d03f36f047574f8ce57fdcfd79b26d9b20ca01dba69ce7c71a3e9a277f0105d0",
|
||||
"adminx/modules/Dashboard/view/widgets/shortcuts.twig": "af165425ea7889cdc3edbd72c7f0dd9dc5c4c06486bc4ab37816112cfb696718",
|
||||
"adminx/modules/Dashboard/view/widgets/system-kpis.twig": "52dab540a189443b76811ac4b457a566cf90134366749aa9c7d82e2d23ffdaa3",
|
||||
"adminx/modules/Database/Backup.php": "131bcff544bf3d2093ae5d14d984070fe5819bf0ae1aa9ec02a48d42b85d1e1b",
|
||||
"adminx/modules/Database/Backup.php": "87c9ea28ceda37db03bd92504be624a02d9de77a7ec6d40b2ebdff94b99ed4fc",
|
||||
"adminx/modules/Database/BackupRestore.php": "1fb34abdec27ed24665ec0749f4ed44167f3881d3477129d5d4de6c7b36d6653",
|
||||
"adminx/modules/Database/Controller.php": "87577df32e3cacc36edb57747ac9169d0e32a30b2b2f11dbbc4db18be486e3e6",
|
||||
"adminx/modules/Database/Model.php": "52c23131d07bc798fb2a3489f91ae909985e60d2965e3cd5fbaf5ab441d85477",
|
||||
@@ -292,7 +292,7 @@
|
||||
"adminx/modules/Database/migrations/001_mysql_legacy_index_compatibility.php": "e9a021b5643933d6ca000d54bd1a12e339080939aa84fd3ec28d0093a5b8222c",
|
||||
"adminx/modules/Database/migrations/002_module_lifecycle_recovery.php": "779b04c8b35f3d81657848016536a9e3ef70fb76dd3ce5b1ba4b4a72166f7a2d",
|
||||
"adminx/modules/Database/migrations/003_document_fields_text_composite_index.php": "541a8df87dd5fec6946f26dcca88f78a2bf5e09608f9300baa8532202020ffbf",
|
||||
"adminx/modules/Database/module.php": "1592d603f73695883643df7aa3fe90c257cd120f99e272b4759b0cdf5c3d3da3",
|
||||
"adminx/modules/Database/module.php": "cea196cf082d3f2f07994cb40da01cba987945ef69f6752069a91a219c122f92",
|
||||
"adminx/modules/Database/view/index.twig": "4e3d3a8ccdeeb2812d4b6d3ae471c8f162d8ac0e30c5dfcc4847356b4cc063a9",
|
||||
"adminx/modules/Directories/Controller.php": "bae664ccff5a63a215fa1b59b721d0b0a4055ac3633319476d09889ead0d7d27",
|
||||
"adminx/modules/Directories/Model.php": "ead423e3d0a07badf5a773780ee37d92394240fc753c10fad274a3131db7c2ce",
|
||||
@@ -564,18 +564,20 @@
|
||||
"adminx/modules/Security/module.php": "c8e42bac300bc4ae73cd7997a8d820bcd721291360d54bc28301caf5e142c1c6",
|
||||
"adminx/modules/Security/view/index.twig": "3954407173cc5bedbf58b47dd527acc548e2138f838b4a9a2921e34cdd807c81",
|
||||
"adminx/modules/Settings/Constants.php": "5f8d15924c33f2e23e6f57f2934229e9c16333334f8ce3679793b35e5acbe0c2",
|
||||
"adminx/modules/Settings/Controller.php": "dadaa11752c61da85c099732491630e44c8a4dd6691e2ac4e91d996f3792d810",
|
||||
"adminx/modules/Settings/Model.php": "9e16004c78a03d39db1bdfd5e23b88b77e8afc7b8f57a2a21997602ace277440",
|
||||
"adminx/modules/Settings/Controller.php": "002610f6e6857858aac2589bf387243e1dad44356ea85c0ccb760c02b4725b7d",
|
||||
"adminx/modules/Settings/Model.php": "f8443fa8add58e2328516891befcb987df4928f6a69f2e3550a09dff70710d5b",
|
||||
"adminx/modules/Settings/ProductionDiagnostics.php": "b23d473a5f6e7c231c68abe4d48159b177699b5f0c23020e96252b240f86c87d",
|
||||
"adminx/modules/Settings/Schema.php": "7367374ace3e25fd86e5a829ebedb0358ba38bf5be0cfab9b859f1e5d3cf0525",
|
||||
"adminx/modules/Settings/assets/settings.css": "c8207dc25cdad75f5d2b811a4f8b4444dc48df88d9a0982e9d442f810497bacf",
|
||||
"adminx/modules/Settings/assets/settings.js": "4254d8985d73ff4646799eefdb54deae1f094c2373531cfc22d6692e48b9d1a6",
|
||||
"adminx/modules/Settings/language/en/client.xml": "28cd3896b4574e87d9968ca180bf02ca783181e11448068426f7f23503253acf",
|
||||
"adminx/modules/Settings/assets/settings.css": "1a7d029051b6775c6ac54767f48a5aa326dd361b8868bb10f286a19ef6b4ccc3",
|
||||
"adminx/modules/Settings/assets/settings.js": "e9c4a21204d071a6782c2204a4641e643aad09489f3443d2bf0f7b404ba66e78",
|
||||
"adminx/modules/Settings/language/en/client.xml": "a95de3ff0e95f0bf22861f6c814101dafd58499071720e1b02db2dbb17ef4241",
|
||||
"adminx/modules/Settings/language/en/interface.xml": "34ed3455953a328ef0d57e5d62e7d8db552aea380f99016863e216bae582a06b",
|
||||
"adminx/modules/Settings/language/en/runtime.xml": "b1e2613a192d781927379b7732a1c786e8ffe28965010f9be50b525784032ec6",
|
||||
"adminx/modules/Settings/language/ru/client.xml": "10031d39c8911a075a461acfca9825e20e9d75836f702e41fd864b7af14bf061",
|
||||
"adminx/modules/Settings/language/en/sessions.xml": "a7a139d7cd52714b454660d57400cb9210953a7d51cce6ab6a5d9f89f6315ff4",
|
||||
"adminx/modules/Settings/language/ru/client.xml": "c08caa5022e760437694a8abc98af34a4ff8c185893d37e234fc05ef4f0c2da3",
|
||||
"adminx/modules/Settings/language/ru/interface.xml": "a3ca37cc1dd1f5fc62eb247624225faf5aac4c36383f88a376d864a09031978e",
|
||||
"adminx/modules/Settings/language/ru/runtime.xml": "2c8e5e4216239b0cc848b8c7561eec133db2d9e4218f305a43f4216d40c6b1b9",
|
||||
"adminx/modules/Settings/language/ru/sessions.xml": "1471edf77fe4487ead60e6d44f9f784c8d3a4a1c35d295f1290127e203d697e3",
|
||||
"adminx/modules/Settings/migrations/001_create_settings_tables.sql": "69d880693f1f4a68b5c917035841cf32de8015229504fbb7676992aa71c1e4fc",
|
||||
"adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql": "fc9af9771803b47ad3ddc21bc5ef827dffa6fb0325436132d9a63619716ce292",
|
||||
"adminx/modules/Settings/migrations/003_remove_unused_constants.sql": "20201881c41cd92bef70903deb0b6771d29756a7d60c22f734c57696fa6c7446",
|
||||
@@ -583,8 +585,8 @@
|
||||
"adminx/modules/Settings/migrations/005_order_system_navigation.php": "df82c4966f28ff3b7d9cf184013477c60a4ae635345f21809bd58b94310e8957",
|
||||
"adminx/modules/Settings/migrations/006_create_admin_saved_views.sql": "69cf1cc085a31b2310f82fe55ad6e0abbb6f14707715205e5a53f06334759a99",
|
||||
"adminx/modules/Settings/migrations/007_public_site_url.sql": "7aab89b5e7ea5f68b519322341c90d42b424b7f994d706f949aa326e571f6cbe",
|
||||
"adminx/modules/Settings/module.php": "5f9365f2bfb8d2b7553a8723b83a19936dfc51ad906c8f3e1e1b809ad1d1e913",
|
||||
"adminx/modules/Settings/view/index.twig": "7d64c286f8e580ae51b1529253fd816cdcabf8122a564fcc428ced98bd7b14d6",
|
||||
"adminx/modules/Settings/module.php": "51529a444a3bc4decd38f2abd457949c62844c4eed529388d988193966c8d88a",
|
||||
"adminx/modules/Settings/view/index.twig": "8c5c93abcfc8976f83d98a4fe19b583048604b527745e6eadeeb46b8c7fd144b",
|
||||
"adminx/modules/Templates/Controller.php": "00b1758860293b56b052a8c41f7174d0d97ba1d78e012b80f8e9f29194eb77d4",
|
||||
"adminx/modules/Templates/Model.php": "4f7a9082f462170533c0c140ef085f6a33111d20061b464864eb86066d50289f",
|
||||
"adminx/modules/Templates/Revisions.php": "54522d2b5a05564637902e48dcdff99a51d858b22b159c109a125f2f6c746430",
|
||||
@@ -687,7 +689,7 @@
|
||||
"help/content/presentations.md": "6e349bb4d1d1577617e6bf3d987b86ca6b255f64a47cd1ddb0e38c5242b788cb",
|
||||
"help/content/public-site.md": "9b7cd51d6d36d21daa73b47cb243b84eeb79f0ff48e9eb61c5951e82c24f7c73",
|
||||
"help/content/requests.md": "c008b1026520c85400fb9240cb117fcaf384531c06e859c1e36ccbb3a79a8590",
|
||||
"help/content/rich-editor.md": "7816fce22a9187bd689a7a5e1fd8e9ffd66c7c321836e371d8fe010982804465",
|
||||
"help/content/rich-editor.md": "a519e52089cd4ad18d0e81a52348af197892d3367022d1bf26efbc13af0c0d8a",
|
||||
"help/content/rubrics.md": "2c9842ce5647fc85663b02cef14f141c673694f0a20856c9262b6ccbea436c3c",
|
||||
"help/content/templates.md": "c6fae2ef91c741479d1f34fe66d00e83f2d2ec1aba1a4099773a04a6e1b6552c",
|
||||
"help/content/themes.md": "f522107e2ee27590a40d726f26538a3101925d0d0173fa1e58422089b5418388",
|
||||
@@ -954,6 +956,7 @@
|
||||
"help/modules/search-analytics.md": "0444e0dacabe408358bcdfda4b6b2a475095e8b1c0ffc1361ff58c4db0576d35",
|
||||
"help/modules/search.md": "a39ffbd45e7d5786cd16d2678f5386c55c3081063177f6525b12849da1c251d3",
|
||||
"help/modules/seo-audit.md": "b0fb78eda0e83c0711b7717fcc730e7bc0cb86c6d692f21aa749cc4975f0ee27",
|
||||
"help/modules/shop_analytics-category-contacts.md": "e8e7bdfa0b04ab739b4eb4d96c612948d2378abdcc7552c83557b97dbd6fea4c",
|
||||
"help/modules/site-readiness.md": "2c7f845ece693ef14f23ea2b3b226f38ebfed305fc67bf842fae971f0e12255a",
|
||||
"help/modules/sliders.md": "13c9d0f212bfa880fa965128b4b072a81f14eb4dba2e11c3889f0b6a57c1764f",
|
||||
"help/modules/smsc-auth.md": "361b46fbc4812513f59e8ba15d095607851f835d7765ac396c3efd79ecde10e3",
|
||||
@@ -1060,7 +1063,7 @@
|
||||
"system/App/Common/RuntimeConstants.php": "decb1277dada75ee2b5c7d531f064db7ffecef5b3ad71d306111b2a45121d34f",
|
||||
"system/App/Common/RuntimeDirectoryGuard.php": "2263f100530f7b0a7790f698581dff14181d4e2a917c12ad1ea888e5df1f59b2",
|
||||
"system/App/Common/Session.php": "4a870a109c75ca941bebfd798d64b1396dde7d56ec060d9491f7d71b70a13deb",
|
||||
"system/App/Common/Session/SessionDB.php": "6340cd88d5ce61ca39601347275547293a0b857cdd3978a4293eaa71076f83db",
|
||||
"system/App/Common/Session/SessionDB.php": "dc08d884b54274d8e6d48ae164a3c6fbb018e890ddb14232d5b20922ff3ac179",
|
||||
"system/App/Common/Session/SessionFiles.php": "9be6009241d0cf6f2f909ddc6881a3dc3cf1de0aefd5ef6103de8032c997dbf0",
|
||||
"system/App/Common/Session/SessionNative.php": "3ca6de1038e0b26e03e0797e9b1dc321ec383d85ad058503509d6b369d88168f",
|
||||
"system/App/Common/Settings.php": "d739b499a2d3c893f387cf3f5033815b863edf08414b1565063fb9d5e7d53f0e",
|
||||
@@ -1075,7 +1078,7 @@
|
||||
"system/App/Common/Twig.php": "c6b62e0d1a9576082babe49d9d31e8f9f63016f819a418dd723839851a32c455",
|
||||
"system/App/Common/Twig/TwigExtensions.php": "b9db3c89ea361b8b4ed754741d5692fc05831a7523a7f9832a9128c313a5b8e4",
|
||||
"system/App/Common/UploadPolicy.php": "c6423e3300bab55fdf54bb59573111fc61febe0d10c3af9b143a88f7d0732e49",
|
||||
"system/App/Common/WebMaintenance.php": "a91f9ee8371da6c6a020d9ce39e1f9f7b517b2644ea51ada65f1f487ff337e6b",
|
||||
"system/App/Common/WebMaintenance.php": "22c2f6076f472d7de6931f73ec0ed627c47813d0c7d706aa40f51582db8347cb",
|
||||
"system/App/Content/BasketTables.php": "1c4d1d9a5ff894c6ebb0edbcfd7bb6f791124e46dd8bed3b70fb0ef2c15069a0",
|
||||
"system/App/Content/Blocks/ContentBlockRegistry.php": "53bc204e7dd1f9d61781049627162341a4d7e656783915161fa5c4eae4cfa228",
|
||||
"system/App/Content/CatalogMenuRepository.php": "6ee0890c5407fa37f5f4a88c5e27ca58b27407a8362e012320bc26fd072cf523",
|
||||
@@ -1119,7 +1122,7 @@
|
||||
"system/App/Content/Fields/DocumentMediaFieldType.php": "f2a461a3780354159a915f4f107a479c7326f255357ee82eec25d3bfc658b4d1",
|
||||
"system/App/Content/Fields/DocumentRelationFieldTrait.php": "eb397fd4aabeea93cf4d280f7c680f10d92a17596bafaea8c3eddc4bedb17c78",
|
||||
"system/App/Content/Fields/DocumentRelationIndex.php": "981b582526d7dae372606c1459023027165e7cb47f5bac14e4735184c652568c",
|
||||
"system/App/Content/Fields/DocumentRelationValue.php": "44abfd5820a0e58f7d7bdfe3cc460bb0db3ff33433bb94aa50dc3d4ef9b9152c",
|
||||
"system/App/Content/Fields/DocumentRelationValue.php": "9fb69a46b6f9e8ce6a8d3df879e4911df576cd182ffdc2d46a568eff1097c407",
|
||||
"system/App/Content/Fields/FieldConditionEvaluator.php": "93ef44322ee4da475a2d7b7c0964d13578a67ededb3da8a5d7e5ebcd05aecf66",
|
||||
"system/App/Content/Fields/FieldContext.php": "97e4d4171bba1ce5c5185068c33eaeb91fdfab670b1ce3fc9f56e10f5cca1d0b",
|
||||
"system/App/Content/Fields/FieldInstallationAudit.php": "0e9817806c2a8b1f9ca64ce9c4f8bbc97942942d7e77fbb9fd62dbf6070a1216",
|
||||
@@ -1386,13 +1389,14 @@
|
||||
"system/App/Helpers/TagParser.php": "7cf90d3af459df482aba5594873bd6807f9944288061d06934668e4bb9c18540",
|
||||
"system/App/Helpers/Url.php": "9a5936995f171b83ef6b254f64bac00296333ff2289e155ae2f6b589eadabe7c",
|
||||
"system/App/Helpers/Valid.php": "d5ebfabf53755ae3e66acade5dcaebd9ca0f5f151f988e1fa0a51d3dba9a0983",
|
||||
"system/App/Helpers/Xlsx.php": "5281e0814d64754287b39074637602f095311930dc6471e3e98d7eb54ed2419d",
|
||||
"system/App/Helpers/Zip.php": "cf34358f6908b026e745bb94e5355b4376d46d31d3dfe78cabbf118c04f745a8",
|
||||
"system/bootstrap.php": "01287f2ec1724095accdb87968e69f19cc438c732544e8160d39ab2a994bebad",
|
||||
"system/distribution.json": "c498272f17ce6f0fbd79401fb264089d6380799a0a102c7f6e5c2fa2149439f7",
|
||||
"system/language/ru/lang.xml": "7d46cc9822f064a1af4d6528ccfe2dd4a9cc318912e327fd807befd2ca3dfd11",
|
||||
"system/preload.php": "66bfb1aaf36ba85945b729b09a37e96ead0be1cc2749ebd9abd4b9da4f25ba0e",
|
||||
"system/public.php": "3f72b5bf44bc0ba5ba6fb6b3adc2e2f9d5e9902d9cdfa3d7b748f4aebd179b87",
|
||||
"system/release.php": "2b574ac66c0b98ee791f231d29b371e2936c202c11bd144912c85bad60b2321d",
|
||||
"system/release.php": "36c6c4f2b04dbc94d919d280e1688dd22c55bb0ccef4dd8964d19740715d2ad8",
|
||||
"system/vendor/Captcha/fonts/palatino_linotype_bold.png": "38b6a1d4a4bd1d0c056205119c5e24176913ac0d898371f53a0339952a319365",
|
||||
"system/vendor/Captcha/fonts/perpetua_bold.png": "55dbbe97dae128dea47b5e8d640414b3d813119c854c688037a20a581ec8fdc4",
|
||||
"system/vendor/Captcha/fonts/times_bold.png": "d3d32ce1f418e3c3c3b1afe3b6acabadb36207bc12368f5e86420cf1a529ff8f",
|
||||
@@ -1594,7 +1598,7 @@
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Style.php": "404c7d877ec66279cafa0771271bf784da77f78290cb242155188532208ff808",
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Theme.php": "f2b4a872bc71dbc634f37aefbda7c9ee3fdd85756ecea6ce8f9e218fe9c15ffc",
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Workbook.php": "1f8f82bbb3885643d44a905ef37280e2bc0af48c3a5c4a1de2e2882d6df9a81b",
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Worksheet.php": "efad6252b15360c61874a05b8439e918d1a0464351f91ec68b2b70c82c19028a",
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/Worksheet.php": "3610aa0b0e181bb1a4da930a36ea88e7a42cb683ee27cc82e5a8987fb5a599ef",
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel2007/WriterPart.php": "4ae0007e93275ab1408e1220e7e8e825f772194212e7af8b7b195220df33768e",
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel5.php": "3a0eb8ef3d9c47a6a64dd6a93d144ec432a5579335553b2d3439c64423e81350",
|
||||
"system/vendor/PHPExcel/PHPExcel/Writer/Excel5/BIFFwriter.php": "6b88b9a269914cfd0598bdcec144c71e3af32803f8998b9c83d25a422a79c9c5",
|
||||
@@ -2544,9 +2548,11 @@
|
||||
"adminx/modules/Settings/language/en/client.xml": "core",
|
||||
"adminx/modules/Settings/language/en/interface.xml": "core",
|
||||
"adminx/modules/Settings/language/en/runtime.xml": "core",
|
||||
"adminx/modules/Settings/language/en/sessions.xml": "core",
|
||||
"adminx/modules/Settings/language/ru/client.xml": "core",
|
||||
"adminx/modules/Settings/language/ru/interface.xml": "core",
|
||||
"adminx/modules/Settings/language/ru/runtime.xml": "core",
|
||||
"adminx/modules/Settings/language/ru/sessions.xml": "core",
|
||||
"adminx/modules/Settings/migrations/001_create_settings_tables.sql": "core",
|
||||
"adminx/modules/Settings/migrations/002_normalize_breadcrumb_separator.sql": "core",
|
||||
"adminx/modules/Settings/migrations/003_remove_unused_constants.sql": "core",
|
||||
@@ -2925,6 +2931,7 @@
|
||||
"help/modules/search-analytics.md": "core",
|
||||
"help/modules/search.md": "core",
|
||||
"help/modules/seo-audit.md": "core",
|
||||
"help/modules/shop_analytics-category-contacts.md": "core",
|
||||
"help/modules/site-readiness.md": "core",
|
||||
"help/modules/sliders.md": "core",
|
||||
"help/modules/smsc-auth.md": "core",
|
||||
@@ -3357,6 +3364,7 @@
|
||||
"system/App/Helpers/TagParser.php": "core",
|
||||
"system/App/Helpers/Url.php": "core",
|
||||
"system/App/Helpers/Valid.php": "core",
|
||||
"system/App/Helpers/Xlsx.php": "core",
|
||||
"system/App/Helpers/Zip.php": "core",
|
||||
"system/bootstrap.php": "core",
|
||||
"system/distribution.json": "core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AVE.cms
|
||||
|
||||
**AVE.cms 3.3 build 0.42** — модульная система управления сайтами на PHP.
|
||||
**AVE.cms 3.3 build 0.43** — модульная система управления сайтами на PHP.
|
||||
Она подходит для проектов, в которых структуру содержимого нужно собирать из
|
||||
собственных рубрик, полей, документов, запросов, блоков и шаблонов, не
|
||||
привязываясь к заранее заданному типу сайта.
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
function normalizeMode(mode) {
|
||||
if (!mode) { return 'htmlmixed'; }
|
||||
if (mode === 'html') { return 'htmlmixed'; }
|
||||
if (mode === 'php') { return 'application/x-httpd-php'; }
|
||||
if (mode === 'smarty') { return 'smartymixed'; }
|
||||
if (mode === 'js') { return 'text/javascript'; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,45 @@ export function isWordHtml(html) {
|
||||
return /\bmso-|class=["']?Mso|urn:schemas-microsoft-com:office|Microsoft Word/i.test(html);
|
||||
}
|
||||
|
||||
function normalizeClipboardDocument(doc) {
|
||||
doc.querySelectorAll('br.Apple-interchange-newline, br.ProseMirror-trailingBreak').forEach(node => node.remove());
|
||||
const block = /^(?:BODY|DIV|SECTION|ARTICLE|MAIN|P|H[1-6]|BLOCKQUOTE|PRE|UL|OL|LI|TABLE|THEAD|TBODY|TFOOT|TR|TD|TH|HR)$/;
|
||||
const whitespace = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||||
const remove = [];
|
||||
while (whitespace.nextNode()) {
|
||||
const node = whitespace.currentNode;
|
||||
if (node.textContent.trim() || node.parentElement.closest('pre, code')) { continue; }
|
||||
// HTML indentation between blocks is not an empty paragraph or a hard break.
|
||||
if (block.test((node.previousSibling || {}).nodeName) || block.test((node.nextSibling || {}).nodeName)) {
|
||||
remove.push(node);
|
||||
}
|
||||
}
|
||||
remove.forEach(node => node.remove());
|
||||
|
||||
function padding(node) {
|
||||
if (node.nodeType === 3) { return !node.textContent.replace(/[\s\u200b\ufeff]/g, ''); }
|
||||
if (node.nodeType !== 1 || !/^(?:P|DIV|SPAN|BR)$/.test(node.nodeName)) { return false; }
|
||||
return !node.textContent.replace(/[\s\u200b\ufeff]/g, '') &&
|
||||
Array.from(node.querySelectorAll('*')).every(child => /^(?:P|DIV|SPAN|BR|STRONG|B|EM|I|U|S|STRIKE|DEL)$/.test(child.nodeName));
|
||||
}
|
||||
function trimEdge(parent, edge) {
|
||||
while (parent[edge] && padding(parent[edge])) { parent[edge].remove(); }
|
||||
const node = parent[edge];
|
||||
if (node && /^(?:DIV|SECTION|ARTICLE|MAIN)$/.test(node.nodeName)) { trimEdge(node, edge); }
|
||||
}
|
||||
// Trim only the fragment edges; intentional blank paragraphs inside it stay intact.
|
||||
if (Array.from(doc.body.children).some(node => block.test(node.nodeName)) &&
|
||||
Array.from(doc.body.childNodes).some(node => !padding(node))) {
|
||||
trimEdge(doc.body, 'firstChild');
|
||||
trimEdge(doc.body, 'lastChild');
|
||||
}
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
export function cleanClipboardHtml(html) {
|
||||
return normalizeClipboardDocument(new DOMParser().parseFromString(html, 'text/html'));
|
||||
}
|
||||
|
||||
export function cleanWordHtml(html) {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
doc.querySelectorAll('script,style,meta,link,iframe,object,embed,svg,math,form,input,button').forEach(node => node.remove());
|
||||
@@ -86,5 +125,5 @@ export function cleanWordHtml(html) {
|
||||
node.replaceWith(...Array.from(node.childNodes));
|
||||
}
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
return normalizeClipboardDocument(doc);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import TableRow from '@tiptap/extension-table-row';
|
||||
import TableCell from '@tiptap/extension-table-cell';
|
||||
import TableHeader from '@tiptap/extension-table-header';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import { isWordHtml, cleanWordHtml } from './_word-paste';
|
||||
import { isWordHtml, cleanWordHtml, cleanClipboardHtml } from './_word-paste';
|
||||
import { RichSearch, ContentFragments } from './_rich-extensions';
|
||||
import { tr, readOptions, openSearch, openSymbols, openSnippets, openBlocks, openReview, reviewBeforeSave, installTools } from './_rich-tools';
|
||||
import { installRecovery } from './_rich-recovery';
|
||||
@@ -115,7 +115,7 @@ var EnhancedImage = Image.extend({
|
||||
create: function (textarea) {
|
||||
var options = readOptions(textarea);
|
||||
var preset = textarea.getAttribute('data-rich-preset') || 'full';
|
||||
var root = el('div', { class: 'rich-editor rich-editor-' + preset });
|
||||
var root = el('div', { class: 'rich-editor rich-editor-' + preset + ' rich-editor-show-blocks' });
|
||||
var toolbar = el('div', { class: 'rich-editor-toolbar', role: 'toolbar' });
|
||||
var surface = el('div', { class: 'rich-editor-surface' });
|
||||
var editorEl = el('div', { class: 'rich-editor-content' });
|
||||
@@ -139,7 +139,7 @@ var EnhancedImage = Image.extend({
|
||||
content: textarea.value || '',
|
||||
editorProps: {
|
||||
transformPastedHTML: function (html) {
|
||||
return isWordHtml(html) ? cleanWordHtml(html) : html;
|
||||
return isWordHtml(html) ? cleanWordHtml(html) : cleanClipboardHtml(html);
|
||||
},
|
||||
attributes: {
|
||||
class: 'rich-editor-prose',
|
||||
@@ -200,7 +200,7 @@ var EnhancedImage = Image.extend({
|
||||
if (options.hide_table && name === 'table') { return; }
|
||||
if (options.hide_source && name === 'source') { return; }
|
||||
if (options.hide_align && name.indexOf('align') === 0) { return; }
|
||||
if (options.profile === 'compact' && ['image', 'table', 'code', 'alignCenter', 'alignRight', 'showBlocks'].indexOf(name) !== -1) { return; }
|
||||
if (options.profile === 'compact' && ['image', 'table', 'code', 'alignCenter', 'alignRight'].indexOf(name) !== -1) { return; }
|
||||
group.appendChild(self.button(name, tooltip, iconName, run));
|
||||
}
|
||||
function addBlockSelect() {
|
||||
@@ -411,8 +411,9 @@ var EnhancedImage = Image.extend({
|
||||
overlay.addEventListener('click', function (event) {
|
||||
if (event.target === overlay || event.target.closest('[data-word-cancel]')) { close(); return; }
|
||||
if (!event.target.closest('[data-word-apply]') || apply.disabled || editor.isDestroyed) { return; }
|
||||
var content = html !== null ? html : input.value.split(/\r?\n/).map(function (line) { return '<p>' + self.escape(line) + '</p>'; }).join('');
|
||||
editor.chain().focus().insertContentAt(selection, content).run();
|
||||
editor.chain().focus().setTextSelection(selection).run();
|
||||
if (html !== null) { editor.view.pasteHTML(html); }
|
||||
else { editor.view.pasteText(input.value); }
|
||||
close();
|
||||
});
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\Settings;
|
||||
use App\Common\DatabaseConfiguration;
|
||||
use App\Common\Db\Core\MysqliDriver;
|
||||
use DB;
|
||||
|
||||
/**
|
||||
@@ -229,8 +231,10 @@
|
||||
gzwrite($fp, "-- AVE.cms full dump\n-- database: " . Model::databaseName() . "\n-- prefixes: {{prefix}}\n-- source-prefix: " . Model::basePrefix() .
|
||||
"\n-- date: " . date('Y-m-d H:i:s') . "\n\nSET NAMES utf8mb4;\nSET FOREIGN_KEY_CHECKS=0;\n\n");
|
||||
|
||||
$mysqli = DB::mysqli();
|
||||
$driver = null;
|
||||
try {
|
||||
$driver = self::openSnapshot();
|
||||
$mysqli = $driver->getMysqli();
|
||||
$total = count($tables);
|
||||
foreach ($tables as $index => $table) {
|
||||
self::notify($progress, array(
|
||||
@@ -254,6 +258,11 @@
|
||||
flock($lock, LOCK_UN);
|
||||
fclose($lock);
|
||||
throw $e;
|
||||
} finally {
|
||||
if ($driver !== null) {
|
||||
$driver->getMysqli()->rollback();
|
||||
$driver->close();
|
||||
}
|
||||
}
|
||||
|
||||
flock($lock, LOCK_UN);
|
||||
@@ -284,24 +293,22 @@
|
||||
$portableTable = Model::portableTableName($table);
|
||||
gzwrite($fp, "\n-- ----- Table `{$portableTable}` -----\nDROP TABLE IF EXISTS `{$portableTable}`;\n");
|
||||
|
||||
$create = (array) DB::query('SHOW CREATE TABLE `' . $table . '`')->getObject();
|
||||
$result = $mysqli->query('SHOW CREATE TABLE `' . $table . '`');
|
||||
if (!$result) { throw new \RuntimeException('Не удалось прочитать структуру таблицы ' . $table . ': ' . $mysqli->error); }
|
||||
$create = $result->fetch_assoc();
|
||||
$result->free();
|
||||
$ddl = isset($create['Create Table']) ? $create['Create Table'] : '';
|
||||
$enumFallbacks = SqlDumpReader::enumFallbacks($ddl);
|
||||
if ($ddl !== '') {
|
||||
gzwrite($fp, self::portableDdl($ddl) . ";\n\n");
|
||||
}
|
||||
|
||||
//-- Данные пакетами, чтобы не держать всё в памяти.
|
||||
$offset = 0;
|
||||
$batch = 500;
|
||||
do {
|
||||
$rows = DB::query('SELECT * FROM `' . $table . '` LIMIT ' . $batch . ' OFFSET ' . $offset)->getAll();
|
||||
if (!$rows) {
|
||||
break;
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$row = (array) $row;
|
||||
// A single streaming SELECT cannot repeat or skip rows when writers change offsets.
|
||||
$result = $mysqli->query('SELECT * FROM `' . $table . '`', MYSQLI_USE_RESULT);
|
||||
if (!$result) { throw new \RuntimeException('Не удалось прочитать таблицу ' . $table . ': ' . $mysqli->error); }
|
||||
$count = 0;
|
||||
try {
|
||||
while ($row = $result->fetch_assoc()) {
|
||||
$cols = array();
|
||||
$vals = array();
|
||||
foreach ($row as $col => $val) {
|
||||
@@ -318,22 +325,40 @@
|
||||
}
|
||||
|
||||
gzwrite($fp, 'INSERT INTO `' . $portableTable . '` (' . implode(',', $cols) . ') VALUES (' . implode(',', $vals) . ");\n");
|
||||
$count++;
|
||||
if ($count % 5000 === 0) {
|
||||
self::notify($progress, array('table' => $table, 'current' => (int) $current, 'total' => (int) $total, 'rows' => $count));
|
||||
}
|
||||
}
|
||||
|
||||
$offset += $batch;
|
||||
if ($offset === $batch || $offset % 5000 === 0 || count($rows) < $batch) {
|
||||
self::notify($progress, array(
|
||||
'table' => $table,
|
||||
'current' => (int) $current,
|
||||
'total' => (int) $total,
|
||||
'rows' => $offset,
|
||||
));
|
||||
}
|
||||
} while (count($rows) === $batch);
|
||||
if ($mysqli->errno) { throw new \RuntimeException('Ошибка чтения таблицы ' . $table . ': ' . $mysqli->error); }
|
||||
} finally {
|
||||
$result->free();
|
||||
}
|
||||
|
||||
self::notify($progress, array('table' => $table, 'current' => (int) $current, 'total' => (int) $total, 'rows' => $count));
|
||||
|
||||
gzwrite($fp, "\n");
|
||||
}
|
||||
|
||||
/** Keep the read snapshot independent of the caller and progress callbacks. */
|
||||
protected static function openSnapshot()
|
||||
{
|
||||
$config = DatabaseConfiguration::all();
|
||||
$config['dbname'] = Model::databaseName();
|
||||
$driver = new MysqliDriver($config);
|
||||
try {
|
||||
foreach (array('SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ', 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY') as $sql) {
|
||||
if (!$driver->query($sql)) { throw new \RuntimeException('Не удалось открыть согласованный снимок БД: ' . $driver->error()); }
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$driver->close();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
return $driver;
|
||||
}
|
||||
|
||||
/** Заменяет префикс только внутри SQL-идентификаторов, не затрагивая данные строк. */
|
||||
protected static function portableDdl($ddl)
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
return [
|
||||
'code' => 'database',
|
||||
'name' => 'База данных',
|
||||
'version' => '0.2.2',
|
||||
'version' => '0.2.3',
|
||||
|
||||
'permissions' => [
|
||||
'key' => 'database',
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
} elseif ($section === 'maintenance') {
|
||||
$data['cache_rows'] = Model::cacheRows();
|
||||
$data['maintenance_counts'] = Model::maintenanceCounts();
|
||||
$data['public_sessions'] = Model::publicSessionStatus();
|
||||
} elseif ($section === 'diagnostics') {
|
||||
$data['production_diagnostics'] = ProductionDiagnostics::run();
|
||||
} elseif ($section === 'files') {
|
||||
@@ -242,11 +243,20 @@
|
||||
return $err;
|
||||
}
|
||||
|
||||
$result = Model::clearMaintenance(isset($params['target']) ? $params['target'] : '');
|
||||
try {
|
||||
$result = Model::clearMaintenance(isset($params['target']) ? $params['target'] : '', Request::postInt('cutoff', 0));
|
||||
} catch (\Throwable $e) {
|
||||
return $this->error('Не удалось очистить данные: ' . $e->getMessage(), array(), 422);
|
||||
}
|
||||
|
||||
if ($result === false) {
|
||||
return $this->error('Неизвестная операция обслуживания', array(), 404);
|
||||
}
|
||||
|
||||
if ($result['target'] === 'sessions' && $result['count'] > 0) {
|
||||
AuditLog::record('settings.sessions_cleared', array('actor_id' => Auth::id(), 'target_type' => 'public_sessions', 'meta' => array('deleted' => $result['count'], 'cutoff' => $result['cutoff'])));
|
||||
}
|
||||
|
||||
return $this->success('Данные очищены', array('data' => $result));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
|
||||
use App\Common\Cache;
|
||||
use App\Common\Settings;
|
||||
use App\Common\Lock;
|
||||
use App\Common\Session\SessionDB;
|
||||
use App\Common\WebMaintenance;
|
||||
use App\Common\FileCacheInvalidator;
|
||||
use App\Common\SystemTables;
|
||||
use App\Content\CatalogTables;
|
||||
@@ -299,8 +302,36 @@
|
||||
);
|
||||
}
|
||||
|
||||
public static function clearMaintenance($target)
|
||||
public static function publicSessionStatus()
|
||||
{
|
||||
$previousMode = DB::$throw_exception_on_error;
|
||||
DB::$throw_exception_on_error = true;
|
||||
try {
|
||||
return array_merge(array('available' => true, 'error' => ''), SessionDB::expirationStats(), WebMaintenance::sessionCleanupStatus());
|
||||
} catch (\Throwable $e) {
|
||||
return array('available' => false, 'error' => $e->getMessage());
|
||||
} finally {
|
||||
DB::$throw_exception_on_error = $previousMode;
|
||||
}
|
||||
}
|
||||
|
||||
public static function clearMaintenance($target, $cutoff = 0)
|
||||
{
|
||||
if ((string) $target === 'sessions') {
|
||||
if (DB::$transaction_in_progress) { throw new \RuntimeException('Очистка сессий недоступна внутри транзакции.'); }
|
||||
$cutoff = (int) $cutoff > 0 ? min(time(), (int) $cutoff) : time();
|
||||
$previousMode = DB::$throw_exception_on_error;
|
||||
DB::$throw_exception_on_error = true;
|
||||
try {
|
||||
return Lock::run('web-maintenance:sessions:v1:' . PublicShellTables::table('sessions'), function () use ($cutoff) {
|
||||
$count = SessionDB::pruneExpired(1000, $cutoff);
|
||||
return array('target' => 'sessions', 'count' => $count, 'done' => !SessionDB::hasExpired($cutoff), 'cutoff' => $cutoff);
|
||||
}, 0.0, true);
|
||||
} finally {
|
||||
DB::$throw_exception_on_error = $previousMode;
|
||||
}
|
||||
}
|
||||
|
||||
$tables = array(
|
||||
'views' => ContentTables::table('view_count'),
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
}
|
||||
.settings-card {
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-layout-card {
|
||||
overflow: hidden;
|
||||
@@ -106,6 +107,7 @@
|
||||
}
|
||||
.settings-stack {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.settings-section-head {
|
||||
@@ -122,6 +124,10 @@
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-section-title > div {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.settings-head-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
@@ -475,6 +481,60 @@
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
}
|
||||
.settings-session-body {
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.settings-session-body .alert,
|
||||
.settings-session-body p {
|
||||
margin: 0;
|
||||
}
|
||||
.settings-session-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px 40px;
|
||||
margin: 0;
|
||||
}
|
||||
.settings-session-stats dt {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.settings-session-stats dd {
|
||||
margin: 4px 0 0;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.settings-session-progress-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
min-height: 32px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.settings-session-error {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.settings-session-progress[hidden],
|
||||
[data-session-stop][hidden] {
|
||||
display: none;
|
||||
}
|
||||
[data-session-maintenance] .settings-section-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
[data-session-maintenance] .settings-section-head .settings-section-title {
|
||||
flex: 1 1 260px;
|
||||
min-width: 0;
|
||||
}
|
||||
[data-session-maintenance] .settings-section-head > .btn {
|
||||
flex: 0 1 auto;
|
||||
white-space: normal;
|
||||
}
|
||||
.settings-maintenance-breakdown {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
dirty: false,
|
||||
layoutDrag: null,
|
||||
sectionDrag: null,
|
||||
sessionCleanup: null,
|
||||
|
||||
init: function () {
|
||||
this.root = document.querySelector('[data-settings-page]');
|
||||
@@ -34,6 +35,11 @@
|
||||
if (cacheClear) { self.clearCache(cacheClear.getAttribute('data-cache-clear')); }
|
||||
var maintenanceClear = e.target.closest('[data-maintenance-clear]');
|
||||
if (maintenanceClear) { self.clearMaintenance(maintenanceClear.getAttribute('data-maintenance-clear')); }
|
||||
if (e.target.closest('[data-session-cleanup]')) { self.confirmSessionCleanup(); }
|
||||
if (e.target.closest('[data-session-stop]') && self.sessionCleanup) {
|
||||
self.sessionCleanup.stopped = true;
|
||||
e.target.closest('[data-session-stop]').disabled = true;
|
||||
}
|
||||
var systemFile = e.target.closest('[data-system-file-edit]');
|
||||
if (systemFile) { self.openSystemFile(systemFile.getAttribute('data-system-file-edit')); }
|
||||
if (e.target.closest('[data-constant-new]')) { self.fillConstantNew(); }
|
||||
@@ -433,6 +439,70 @@
|
||||
});
|
||||
},
|
||||
|
||||
confirmSessionCleanup: function () {
|
||||
if (this.sessionCleanup) { return; }
|
||||
var self = this;
|
||||
Adminx.Confirm.open({
|
||||
kind: 'warning',
|
||||
title: 'Очистить просроченные сессии?',
|
||||
message: 'Действующие сессии, заказы и сохранённые брошенные корзины останутся. Очистка выполняется порциями; дождитесь завершения на этой странице.',
|
||||
confirmLabel: 'Очистить',
|
||||
onConfirm: function () {
|
||||
if (self.sessionCleanup) { return; }
|
||||
var root = document.querySelector('[data-session-maintenance]');
|
||||
self.sessionCleanup = {root: root, count: 0, total: Number(root.querySelector('[data-session-expired]').textContent) || 0, cutoff: 0, stopped: false};
|
||||
root.querySelector('[data-session-cleanup]').disabled = true;
|
||||
root.querySelector('[data-session-progress]').hidden = false;
|
||||
root.querySelector('[data-session-progress-bar]').style.width = '0%';
|
||||
root.querySelector('[role=progressbar]').setAttribute('aria-valuenow', '0');
|
||||
root.querySelector('[data-session-progress-status]').textContent = Adminx.tr('Очистка…');
|
||||
root.querySelector('[data-session-stop]').hidden = false;
|
||||
root.querySelector('[data-session-stop]').disabled = false;
|
||||
self.clearSessionBatch();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearSessionBatch: function () {
|
||||
var self = this;
|
||||
var job = this.sessionCleanup;
|
||||
if (!job) { return; }
|
||||
if (job.stopped) { self.finishSessionCleanup(false); return; }
|
||||
var form = new FormData();
|
||||
form.append('cutoff', String(job.cutoff));
|
||||
Adminx.Ajax.post(self.base() + '/settings/maintenance/sessions/clear', form).then(function (payload) {
|
||||
var response = payload.data || {};
|
||||
if (!payload.ok || !response.success || !response.data) {
|
||||
throw new Error(response.message || Adminx.tr('Не удалось очистить данные'));
|
||||
}
|
||||
var result = response.data;
|
||||
job.cutoff = result.cutoff;
|
||||
job.count += Number(result.count) || 0;
|
||||
var percent = result.done ? 100 : Math.min(99, Math.round(100 * job.count / Math.max(1, job.total)));
|
||||
job.root.querySelector('[data-session-progress-bar]').style.width = percent + '%';
|
||||
job.root.querySelector('[role=progressbar]').setAttribute('aria-valuenow', String(percent));
|
||||
job.root.querySelector('[data-session-expired]').textContent = String(Math.max(0, job.total - job.count));
|
||||
job.root.querySelector('[data-session-progress-status]').textContent = Adminx.tr('Удалено сессий:') + ' ' + job.count;
|
||||
if (result.done || job.stopped) {
|
||||
self.finishSessionCleanup(Boolean(result.done));
|
||||
} else {
|
||||
window.setTimeout(function () { self.clearSessionBatch(); }, 200);
|
||||
}
|
||||
}).catch(function (error) { self.finishSessionCleanup(false, error.message || Adminx.tr('Ошибка сети')); });
|
||||
},
|
||||
|
||||
finishSessionCleanup: function (done, error) {
|
||||
var job = this.sessionCleanup;
|
||||
if (!job) { return; }
|
||||
job.root.querySelector('[data-session-cleanup]').disabled = false;
|
||||
job.root.querySelector('[data-session-stop]').hidden = true;
|
||||
var message = (done ? Adminx.tr('Очистка завершена.') : Adminx.tr('Очистка остановлена.')) + ' ' + Adminx.tr('Удалено сессий:') + ' ' + job.count;
|
||||
if (done) { job.root.querySelector('[data-session-expired]').textContent = '0'; }
|
||||
job.root.querySelector('[data-session-progress-status]').textContent = error ? message + '. ' + error : message;
|
||||
Adminx.Toast.show(error || message, error ? 'error' : (done ? 'success' : 'info'));
|
||||
this.sessionCleanup = null;
|
||||
},
|
||||
|
||||
openSystemFile: function (code) {
|
||||
var form = document.querySelector('[data-system-file-form]');
|
||||
if (!form) { return; }
|
||||
|
||||
@@ -64,4 +64,10 @@
|
||||
<phrase data="runtime_b27389d8c15db395">Constant not found</phrase>
|
||||
<phrase data="runtime_babdb58139be97ea">Settings saved</phrase>
|
||||
<phrase data="runtime_e70084d8c3dab3fe">Data cleared</phrase>
|
||||
<phrase data="settings_sessions_confirm">Clear expired sessions?</phrase>
|
||||
<phrase data="settings_sessions_confirm_hint">Valid sessions, orders and saved abandoned carts will remain. Cleanup runs in batches; keep this page open until it finishes.</phrase>
|
||||
<phrase data="settings_sessions_working">Cleaning up...</phrase>
|
||||
<phrase data="settings_sessions_count">Sessions deleted:</phrase>
|
||||
<phrase data="settings_sessions_done">Cleanup completed.</phrase>
|
||||
<phrase data="settings_sessions_stopped">Cleanup stopped.</phrase>
|
||||
</language>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="settings_sessions_title">Public sessions</phrase>
|
||||
<phrase data="settings_sessions_hint">Only expired sessions are deleted. Valid sessions, orders and saved abandoned carts are not affected.</phrase>
|
||||
<phrase data="settings_sessions_clear">Clear expired sessions</phrase>
|
||||
<phrase data="settings_sessions_expired">Expired</phrase>
|
||||
<phrase data="settings_sessions_active">Valid</phrase>
|
||||
<phrase data="settings_sessions_progress">Expired session cleanup</phrase>
|
||||
<phrase data="settings_sessions_stop">Stop</phrase>
|
||||
<phrase data="settings_sessions_other_handler">Database sessions are not currently used. Automatic cleanup of this table is disabled.</phrase>
|
||||
<phrase data="settings_sessions_web">Post-response cleanup is available. Limit: 1000 records per minute. Use Scheduler and cron to run without visitor requests.</phrase>
|
||||
<phrase data="settings_sessions_no_web">Post-response cleanup is unavailable in this PHP environment. Configure cron in Scheduler for regular cleanup.</phrase>
|
||||
<phrase data="settings_sessions_last">Last automatic attempt:</phrase>
|
||||
<phrase data="settings_sessions_deleted">Deleted:</phrase>
|
||||
<phrase data="settings_sessions_no_runs">No automatic attempts have been recorded since the update.</phrase>
|
||||
<phrase data="settings_sessions_size">The table size in megabytes may not decrease immediately: the database reuses freed space. Another optimization is not required to delete records.</phrase>
|
||||
<phrase data="settings_sessions_check_failed">Could not check public sessions.</phrase>
|
||||
<phrase data="settings_sessions_confirm">Clear expired sessions?</phrase>
|
||||
<phrase data="settings_sessions_confirm_hint">Valid sessions, orders and saved abandoned carts will remain. Cleanup runs in batches; keep this page open until it finishes.</phrase>
|
||||
<phrase data="settings_sessions_working">Cleaning up...</phrase>
|
||||
<phrase data="settings_sessions_count">Sessions deleted:</phrase>
|
||||
<phrase data="settings_sessions_done">Cleanup completed.</phrase>
|
||||
<phrase data="settings_sessions_stopped">Cleanup stopped.</phrase>
|
||||
<phrase data="settings_sessions_transaction">Session cleanup is unavailable inside a transaction.</phrase>
|
||||
<phrase data="settings_sessions_failed">Could not clear data:</phrase>
|
||||
<phrase data="settings_sessions_sapi">PHP SAPI:</phrase>
|
||||
<phrase data="settings_sessions_gc_on">enabled</phrase>
|
||||
<phrase data="settings_sessions_gc_off">disabled</phrase>
|
||||
<phrase data="settings_sessions_lifetime">Session lifetime, seconds:</phrase>
|
||||
</language>
|
||||
@@ -64,4 +64,10 @@
|
||||
<phrase data="runtime_b27389d8c15db395">Константа не найдена</phrase>
|
||||
<phrase data="runtime_babdb58139be97ea">Настройки сохранены</phrase>
|
||||
<phrase data="runtime_e70084d8c3dab3fe">Данные очищены</phrase>
|
||||
<phrase data="settings_sessions_confirm">Очистить просроченные сессии?</phrase>
|
||||
<phrase data="settings_sessions_confirm_hint">Действующие сессии, заказы и сохранённые брошенные корзины останутся. Очистка выполняется порциями; дождитесь завершения на этой странице.</phrase>
|
||||
<phrase data="settings_sessions_working">Очистка…</phrase>
|
||||
<phrase data="settings_sessions_count">Удалено сессий:</phrase>
|
||||
<phrase data="settings_sessions_done">Очистка завершена.</phrase>
|
||||
<phrase data="settings_sessions_stopped">Очистка остановлена.</phrase>
|
||||
</language>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<language>
|
||||
<phrase data="settings_sessions_title">Публичные сессии</phrase>
|
||||
<phrase data="settings_sessions_hint">Удаляются только просроченные сессии. Действующие сессии, заказы и сохранённые брошенные корзины не затрагиваются.</phrase>
|
||||
<phrase data="settings_sessions_clear">Очистить просроченные</phrase>
|
||||
<phrase data="settings_sessions_expired">Просроченные</phrase>
|
||||
<phrase data="settings_sessions_active">Действующие</phrase>
|
||||
<phrase data="settings_sessions_progress">Очистка просроченных сессий</phrase>
|
||||
<phrase data="settings_sessions_stop">Остановить</phrase>
|
||||
<phrase data="settings_sessions_other_handler">Сейчас используются не сессии БД. Автоочистка этой таблицы не запускается.</phrase>
|
||||
<phrase data="settings_sessions_web">Веб-очистка после отправки ответа доступна. Лимит: 1000 записей в минуту. Для запуска без посещений используйте Планировщик и cron.</phrase>
|
||||
<phrase data="settings_sessions_no_web">Очистка после ответа недоступна в этом PHP-окружении. Для регулярной очистки настройте cron в модуле «Планировщик».</phrase>
|
||||
<phrase data="settings_sessions_last">Последняя автоматическая попытка:</phrase>
|
||||
<phrase data="settings_sessions_deleted">Удалено:</phrase>
|
||||
<phrase data="settings_sessions_no_runs">После обновления автоматические запуски пока не зарегистрированы.</phrase>
|
||||
<phrase data="settings_sessions_size">Размер таблицы в мегабайтах может не уменьшиться сразу: БД повторно использует освобождённое место. Повторная оптимизация для очистки записей не требуется.</phrase>
|
||||
<phrase data="settings_sessions_check_failed">Не удалось проверить публичные сессии.</phrase>
|
||||
<phrase data="settings_sessions_confirm">Очистить просроченные сессии?</phrase>
|
||||
<phrase data="settings_sessions_confirm_hint">Действующие сессии, заказы и сохранённые брошенные корзины останутся. Очистка выполняется порциями; дождитесь завершения на этой странице.</phrase>
|
||||
<phrase data="settings_sessions_working">Очистка…</phrase>
|
||||
<phrase data="settings_sessions_count">Удалено сессий:</phrase>
|
||||
<phrase data="settings_sessions_done">Очистка завершена.</phrase>
|
||||
<phrase data="settings_sessions_stopped">Очистка остановлена.</phrase>
|
||||
<phrase data="settings_sessions_transaction">Очистка сессий недоступна внутри транзакции.</phrase>
|
||||
<phrase data="settings_sessions_failed">Не удалось очистить данные:</phrase>
|
||||
<phrase data="settings_sessions_sapi">Режим PHP:</phrase>
|
||||
<phrase data="settings_sessions_gc_on">включён</phrase>
|
||||
<phrase data="settings_sessions_gc_off">выключен</phrase>
|
||||
<phrase data="settings_sessions_lifetime">Время жизни сессии, секунд:</phrase>
|
||||
</language>
|
||||
@@ -17,7 +17,7 @@
|
||||
return array(
|
||||
'code' => 'settings',
|
||||
'name' => 'Настройки',
|
||||
'version' => '0.1.4',
|
||||
'version' => '0.1.5',
|
||||
|
||||
'permissions' => array(
|
||||
'key' => 'settings',
|
||||
|
||||
@@ -414,6 +414,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card" data-session-maintenance>
|
||||
<div class="settings-section-head">
|
||||
<div class="settings-section-title">
|
||||
<span class="icon-tile settings-head-icon" style="--tile-bg:var(--blue-100);--tile-fg:var(--blue-600)"><i class="ti ti-clock-x"></i></span>
|
||||
<div><h2>Публичные сессии</h2><p class="text-secondary">Удаляются только просроченные сессии. Действующие сессии, заказы и сохранённые брошенные корзины не затрагиваются.</p></div>
|
||||
</div>
|
||||
{% if can_manage and public_sessions.available %}
|
||||
<button class="btn btn-danger-soft btn-sm" type="button" data-session-cleanup><i class="ti ti-trash"></i>Очистить просроченные</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="settings-session-body">
|
||||
{% if public_sessions.available %}
|
||||
<dl class="settings-session-stats">
|
||||
<div><dt>Просроченные</dt><dd data-session-expired>{{ public_sessions.expired }}</dd></div>
|
||||
<div><dt>Действующие</dt><dd>{{ public_sessions.active }}</dd></div>
|
||||
</dl>
|
||||
<div class="settings-session-progress" data-session-progress hidden>
|
||||
<div class="progress" role="progressbar" aria-label="Очистка просроченных сессий" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="progress-bar" data-session-progress-bar style="width:0%"></div></div>
|
||||
<div class="settings-session-progress-row"><span role="status" aria-live="polite" data-session-progress-status></span>{% if can_manage %}<button class="btn btn-ghost btn-sm" type="button" data-session-stop hidden><i class="ti ti-player-pause"></i>Остановить</button>{% endif %}</div>
|
||||
</div>
|
||||
<div class="alert {{ public_sessions.last and public_sessions.last.error ? 'alert-danger' : 'alert-info' }}">
|
||||
<i class="ti ti-info-circle"></i>
|
||||
<div>
|
||||
{% if not public_sessions.database %}
|
||||
Сейчас используются не сессии БД. Автоочистка этой таблицы не запускается.
|
||||
{% elseif public_sessions.after_response %}
|
||||
Веб-очистка после отправки ответа доступна. Лимит: 1000 записей в минуту. Для запуска без посещений используйте Планировщик и cron.
|
||||
{% else %}
|
||||
Очистка после ответа недоступна в этом PHP-окружении. Для регулярной очистки настройте cron в модуле «Планировщик».
|
||||
{% endif %}
|
||||
{% if public_sessions.last %}
|
||||
<div>Последняя автоматическая попытка: {{ public_sessions.last.at|date('d.m.Y H:i:s') }}. Удалено: {{ public_sessions.last.deleted }}.</div>
|
||||
{% if public_sessions.last.error %}<div class="settings-session-error">{{ public_sessions.last.error }}</div>{% endif %}
|
||||
{% else %}
|
||||
<div>После обновления автоматические запуски пока не зарегистрированы.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-secondary text-xs">Режим PHP: {{ public_sessions.sapi }}. PHP GC: {{ public_sessions.gc_enabled ? 'включён' : 'выключен' }}. Время жизни сессии, секунд: {{ public_sessions.lifetime }}.</p>
|
||||
<p class="text-secondary text-xs">Размер таблицы в мегабайтах может не уменьшиться сразу: БД повторно использует освобождённое место. Повторная оптимизация для очистки записей не требуется.</p>
|
||||
{% else %}
|
||||
<div class="alert alert-danger"><i class="ti ti-alert-triangle"></i><div>Не удалось проверить публичные сессии.<div class="settings-session-error">{{ public_sessions.error }}</div></div></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card settings-card">
|
||||
<div class="settings-section-head"><div class="settings-section-title"><span class="icon-tile settings-head-icon" style="--tile-bg:var(--red-100);--tile-fg:var(--red-600)"><i class="ti ti-database-minus"></i></span><div><h2>Служебные данные</h2><p class="text-secondary">Глобальная очистка ревизий редакторов и подневных счётчиков.</p></div></div></div>
|
||||
<div class="settings-maintenance-actions">
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
- **Вставить из Word**: сохраняет структуру и выделения, удаляет оформление Word.
|
||||
Обычная вставка автоматически очищается при распознавании Word. Изображения из Word
|
||||
добавляйте через медиатеку отдельно.
|
||||
Оба способа используют один механизм вставки: отступы HTML и пустые абзацы
|
||||
по краям скопированного фрагмента не добавляют лишних строк. Пустые абзацы
|
||||
внутри фрагмента сохраняются; уже записанный текст автоматически не очищается.
|
||||
- **Показать блоки**: включено по умолчанию. Пунктирные границы и названия тегов
|
||||
помогают различать абзацы, заголовки и списки. Кнопка скрывает или показывает
|
||||
их только в текущем редакторе; на сайте и в сохранённом HTML их нет.
|
||||
|
||||
## Настройки В Рубрике
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Контакты по категориям
|
||||
|
||||
Откройте **Магазин → Аналитика** и выберите **Контакты по категориям**
|
||||
в правом меню разделов.
|
||||
|
||||
| До | После |
|
||||
| --- | --- |
|
||||
| Заказы и корзины приходилось сверять с категориями вручную | Один отчёт с выбором периода и товарных категорий |
|
||||
| Контакты и состав нужно было собирать из отдельных записей | Заказы и брошенные корзины показаны отдельно, доступны две выгрузки XLSX |
|
||||
| Категории для еженедельного отчёта выбирались заново | Набор сохраняется в персональном представлении; даты меняются перед выгрузкой |
|
||||
|
||||
## Отбор
|
||||
|
||||
Достаточно **одного товара** из выбранных категорий, чтобы заказ или корзина
|
||||
попали в список потенциального опта. Статус покупателя в базе не меняется.
|
||||
|
||||
Выберите даты, раскройте категории, отметьте нужные разделы и нажмите
|
||||
**Показать**. По умолчанию включены подкатегории и отбор только записей
|
||||
с непустым телефоном или email.
|
||||
|
||||
В режиме **Заказы** доступен фильтр отмен и оплаты. В режиме
|
||||
**Брошенные корзины** можно отделить отработанные от неотработанных.
|
||||
Архивные корзины включены; корзины, уже превратившиеся в заказ, исключены.
|
||||
Открытая корзина считается брошенной после часа бездействия.
|
||||
|
||||
## Даты и суммы
|
||||
|
||||
У заказов период относится к **созданию заказа**, у корзин — к **последней
|
||||
активности**. Поэтому старая корзина, которую заполняли на этой неделе,
|
||||
может попасть в недельный отчёт.
|
||||
|
||||
Категории определяются по текущим привязкам товаров. Состав и сумма берутся
|
||||
из сохранённых данных без пересчёта по текущему прайсу. Сумма — за весь
|
||||
заказ/корзину, не только за подходящие позиции. Итог заказов исключает отмены.
|
||||
|
||||
## Выгрузка
|
||||
|
||||
**XLSX** скачивает всю выборку, не только текущую страницу. Одна запись —
|
||||
одна строка; несколько товаров в ячейке разделены переносами строк.
|
||||
В файле также есть контакты, категории, оплата/состояние, обработка корзины,
|
||||
UTM-источник и кампания, комментарий. Второй лист содержит параметры отбора.
|
||||
|
||||
Для заказов и брошенных корзин скачиваются отдельные файлы. Период — до
|
||||
93 дней, файл — до 5000 строк; при превышении нужно сузить выборку.
|
||||
|
||||
Сохраните категории кнопкой закладки в «Сохранённых представлениях».
|
||||
В следующий раз выберите набор и измените даты: сохранённые даты не
|
||||
сдвигаются автоматически. Быстрая ссылка **Прошлая неделя** выбирает
|
||||
предыдущую календарную неделю с понедельника по воскресенье.
|
||||
|
||||
Для просмотра нужны права на аналитику и заказы, для скачивания также право
|
||||
экспорта аналитики. Готовые файлы содержат персональные контакты покупателей.
|
||||
@@ -17,6 +17,7 @@
|
||||
defined("BASEPATH") || die ('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Content\PublicShellTables;
|
||||
use App\Common\WebMaintenance;
|
||||
use DB;
|
||||
|
||||
class SessionDB
|
||||
@@ -86,13 +87,81 @@
|
||||
return DB::Delete(PublicShellTables::table('sessions'), 'sesskey = %s', $sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить истёкшие сессии.
|
||||
* expire хранит абсолютный timestamp, поэтому сравниваем с time(), а не с time()-maxlifetime.
|
||||
*/
|
||||
public static function gc($maxlifetime)
|
||||
{
|
||||
return (bool) DB::Delete(PublicShellTables::table('sessions'), 'expiry < %i', time());
|
||||
try {
|
||||
if (!WebMaintenance::scheduleSessionCleanup()) {
|
||||
WebMaintenance::pruneDatabaseSessions();
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Session garbage collection failed: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete one indexed batch; never rebuild or optimize the session table here. */
|
||||
public static function pruneExpired($limit = 1000, $cutoff = null)
|
||||
{
|
||||
$limit = max(1, min(1000, (int) $limit));
|
||||
$table = PublicShellTables::table('sessions');
|
||||
$previousMode = DB::$throw_exception_on_error;
|
||||
DB::$throw_exception_on_error = true;
|
||||
$timeouts = null;
|
||||
|
||||
try {
|
||||
$timeouts = DB::query('SELECT @@SESSION.innodb_lock_wait_timeout row_wait, @@SESSION.lock_wait_timeout metadata_wait')->getAssoc();
|
||||
DB::query('SET SESSION innodb_lock_wait_timeout=1, SESSION lock_wait_timeout=1');
|
||||
$expiryIndex = self::expiryIndex($table);
|
||||
$cutoff = self::expirationCutoff($cutoff);
|
||||
$keys = array_column(DB::query('SELECT sesskey FROM %b FORCE INDEX (%b) WHERE expiry <= %i ORDER BY expiry ASC LIMIT %i', $table, $expiryIndex, $cutoff, $limit)->getAll(), 'sesskey');
|
||||
if (!$keys) { return 0; }
|
||||
// Recheck expiry after selection: a concurrent request may renew a session.
|
||||
DB::query('DELETE s FROM %b s FORCE INDEX (PRIMARY) WHERE s.sesskey IN %ls AND s.expiry <= %i', $table, $keys, $cutoff);
|
||||
return (int) DB::affectedRows();
|
||||
} finally {
|
||||
try {
|
||||
if ($timeouts) {
|
||||
DB::query('SET SESSION innodb_lock_wait_timeout=%i, SESSION lock_wait_timeout=%i', (int) $timeouts['row_wait'], (int) $timeouts['metadata_wait']);
|
||||
}
|
||||
} finally {
|
||||
DB::$throw_exception_on_error = $previousMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Exact index-only counts for the maintenance screen, not the public request path. */
|
||||
public static function expirationStats()
|
||||
{
|
||||
$table = PublicShellTables::table('sessions');
|
||||
$cutoff = time();
|
||||
$row = DB::query('SELECT COUNT(*) total, COALESCE(SUM(expiry <= %i),0) expired FROM %b FORCE INDEX (%b)', $cutoff, $table, self::expiryIndex($table))->getAssoc();
|
||||
$expired = (int) $row['expired'];
|
||||
return array('expired' => $expired, 'active' => (int) $row['total'] - $expired, 'cutoff' => $cutoff);
|
||||
}
|
||||
|
||||
public static function hasExpired($cutoff = null)
|
||||
{
|
||||
$table = PublicShellTables::table('sessions');
|
||||
return (bool) DB::query('SELECT 1 FROM %b FORCE INDEX (%b) WHERE expiry <= %i LIMIT 1', $table, self::expiryIndex($table), self::expirationCutoff($cutoff))->getValue();
|
||||
}
|
||||
|
||||
protected static function expirationCutoff($cutoff)
|
||||
{
|
||||
return $cutoff === null ? time() : max(0, min(time(), (int) $cutoff));
|
||||
}
|
||||
|
||||
protected static function expiryIndex($table)
|
||||
{
|
||||
foreach (DB::query('SHOW INDEX FROM %b', $table)->getAll() as $index) {
|
||||
if ((int) $index['Seq_in_index'] === 1 && strtolower((string) $index['Column_name']) === 'expiry'
|
||||
&& strtoupper((string) $index['Index_type']) === 'BTREE') {
|
||||
return (string) $index['Key_name'];
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Session cleanup requires an index starting with expiry in ' . $table);
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
|
||||
@@ -17,17 +17,23 @@
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
use App\Common\Session\SessionFiles;
|
||||
use App\Common\Session\SessionDB;
|
||||
use App\Content\PublicShellTables;
|
||||
|
||||
/** Runs short, idempotent maintenance batches without requiring production CLI. */
|
||||
class WebMaintenance
|
||||
{
|
||||
const INTERVAL = 900;
|
||||
const BATCH_SIZE = 250;
|
||||
const SESSION_INTERVAL = 60;
|
||||
const SESSION_BATCH_SIZE = 1000;
|
||||
|
||||
protected static $ran = false;
|
||||
protected static $sessionCleanupScheduled = false;
|
||||
|
||||
public static function runIfDue()
|
||||
{
|
||||
self::scheduleSessionCleanup();
|
||||
if (self::$ran) { return false; }
|
||||
self::$ran = true;
|
||||
|
||||
@@ -46,6 +52,102 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function scheduleSessionCleanup()
|
||||
{
|
||||
if (!self::usesDatabaseSessions() || !self::responseFinisher()) { return false; }
|
||||
if (!self::$sessionCleanupScheduled) {
|
||||
self::$sessionCleanupScheduled = true;
|
||||
register_shutdown_function(array(__CLASS__, 'finishSessionCleanup'));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function finishSessionCleanup()
|
||||
{
|
||||
if (!self::$sessionCleanupScheduled) { return; }
|
||||
self::$sessionCleanupScheduled = false;
|
||||
try {
|
||||
if (Cache::get(self::sessionCleanupKey())) { return; }
|
||||
$finish = self::responseFinisher();
|
||||
if (!$finish) { return; }
|
||||
if (session_status() === PHP_SESSION_ACTIVE) { session_write_close(); }
|
||||
// A shutdown callback alone does not release an Apache HTTP response.
|
||||
if (call_user_func($finish) === false) { return; }
|
||||
self::pruneDatabaseSessions();
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Web maintenance [expired database sessions] failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared by the post-response callback, PHP GC and the optional scheduler. */
|
||||
public static function pruneDatabaseSessions()
|
||||
{
|
||||
if (!self::usesDatabaseSessions()) { return array('deleted' => 0, 'skipped' => 'handler'); }
|
||||
if (\DB::$transaction_in_progress) { return array('deleted' => 0, 'skipped' => 'transaction'); }
|
||||
$key = self::sessionCleanupKey();
|
||||
if (Cache::get($key)) { return array('deleted' => 0, 'skipped' => 'interval'); }
|
||||
$started = false;
|
||||
|
||||
try {
|
||||
return Lock::run($key, function () use ($key, &$started) {
|
||||
$started = true;
|
||||
if (Cache::get($key)) { return array('deleted' => 0, 'skipped' => 'interval'); }
|
||||
if (!Cache::set($key, time(), self::SESSION_INTERVAL)) {
|
||||
throw new \RuntimeException('Cannot save the session cleanup interval');
|
||||
}
|
||||
|
||||
$deleted = SessionDB::pruneExpired(self::SESSION_BATCH_SIZE);
|
||||
self::recordSessionCleanup($deleted, '');
|
||||
return array('deleted' => $deleted, 'skipped' => '');
|
||||
}, 0.0, true);
|
||||
} catch (\Throwable $e) {
|
||||
if (!$started) { return array('deleted' => 0, 'skipped' => 'busy'); }
|
||||
self::recordSessionCleanup(0, $e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public static function sessionCleanupStatus()
|
||||
{
|
||||
$record = Cache::get(self::sessionCleanupKey() . ':last');
|
||||
return array(
|
||||
'database' => self::usesDatabaseSessions(),
|
||||
'after_response' => self::responseFinisher() !== null,
|
||||
'sapi' => PHP_SAPI,
|
||||
'gc_enabled' => (int) ini_get('session.gc_probability') > 0,
|
||||
'lifetime' => defined('SESSION_LIFETIME') && (int) SESSION_LIFETIME > 0 ? (int) SESSION_LIFETIME : max(1440, (int) ini_get('session.gc_maxlifetime')),
|
||||
'last' => is_array($record) ? $record : null,
|
||||
);
|
||||
}
|
||||
|
||||
protected static function recordSessionCleanup($deleted, $error)
|
||||
{
|
||||
try {
|
||||
Cache::set(self::sessionCleanupKey() . ':last', array('at' => time(), 'deleted' => (int) $deleted, 'error' => (string) $error), 604800);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Cannot record session cleanup result: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
protected static function sessionCleanupKey()
|
||||
{
|
||||
return 'web-maintenance:sessions:v1:' . PublicShellTables::table('sessions');
|
||||
}
|
||||
|
||||
protected static function usesDatabaseSessions()
|
||||
{
|
||||
$handler = defined('SESSION_SAVE_HANDLER') ? strtolower((string) SESSION_SAVE_HANDLER) : 'db';
|
||||
return !in_array($handler, array('file', 'files', 'native'), true);
|
||||
}
|
||||
|
||||
protected static function responseFinisher()
|
||||
{
|
||||
if (PHP_SAPI === 'fpm-fcgi' && function_exists('fastcgi_finish_request')) { return 'fastcgi_finish_request'; }
|
||||
if (PHP_SAPI === 'litespeed' && function_exists('litespeed_finish_request')) { return 'litespeed_finish_request'; }
|
||||
return null;
|
||||
}
|
||||
|
||||
protected static function task($label, callable $callback)
|
||||
{
|
||||
try {
|
||||
|
||||
@@ -104,6 +104,10 @@
|
||||
$candidates[] = (int) $raw;
|
||||
} elseif (ctype_digit($raw)) {
|
||||
$candidates[] = (int) $raw;
|
||||
} elseif (preg_match('/^[0-9|,;\s]+$/D', $raw)) {
|
||||
foreach (preg_split('/[|,;\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $part) {
|
||||
$candidates[] = (int) $part;
|
||||
}
|
||||
} else {
|
||||
foreach (preg_split('/[,;\s]+/', $raw, -1, PREG_SPLIT_NO_EMPTY) as $part) {
|
||||
if (preg_match('/(?:^|\|)(\d+)(?:\|)?$/', trim((string) $part), $match)) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------------------
|
||||
| AVE.cms
|
||||
|--------------------------------------------------------------------------------------
|
||||
| @package AVE.cms
|
||||
| @file system/App/Helpers/Xlsx.php
|
||||
| @author AVE.cms <support@ave-cms.ru>
|
||||
| @copyright 2007-2026 (c) AVE.cms
|
||||
| @link https://ave-cms.ru
|
||||
| @version 3.3
|
||||
*/
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
||||
|
||||
/** Native Excel export: user text is never interpreted as a formula. */
|
||||
class Xlsx
|
||||
{
|
||||
const MAX_ROWS = 5000;
|
||||
|
||||
public static function save($path, $title, array $headers, iterable $rows, array $parameters = array())
|
||||
{
|
||||
require_once BASEPATH . '/system/vendor/PHPExcel/PHPExcel.php';
|
||||
$book = new \PHPExcel();
|
||||
try {
|
||||
$sheet = $book->getActiveSheet();
|
||||
$sheet->setTitle(mb_substr(preg_replace('/[\\\\\/?*\[\]:]/u', '', (string) $title), 0, 31, 'UTF-8') ?: 'Отчёт');
|
||||
self::sheet($sheet, $headers, $rows);
|
||||
if ($parameters) { self::sheet($book->createSheet()->setTitle('Параметры'), array('Параметр', 'Значение'), $parameters); }
|
||||
$book->setActiveSheetIndex(0);
|
||||
\PHPExcel_IOFactory::createWriter($book, 'Excel2007')->save($path);
|
||||
} finally { $book->disconnectWorksheets(); }
|
||||
}
|
||||
|
||||
protected static function sheet($sheet, array $headers, iterable $rows)
|
||||
{
|
||||
if (!$headers || count($headers) > 100) { throw new \InvalidArgumentException('Некорректные столбцы XLSX.'); }
|
||||
$write = function ($number, array $values) use ($sheet, $headers) {
|
||||
foreach (array_values($values) as $column => $value) {
|
||||
if ($column >= count($headers)) { break; }
|
||||
$type = is_int($value) || is_float($value) ? \PHPExcel_Cell_DataType::TYPE_NUMERIC : \PHPExcel_Cell_DataType::TYPE_STRING;
|
||||
if ($type === \PHPExcel_Cell_DataType::TYPE_STRING) {
|
||||
$value = preg_replace('/[\x00-\x08\x0b\x0c\x0e-\x1f]/u', '', (string) $value);
|
||||
if (mb_strlen($value, 'UTF-8') > 32767) { throw new \RuntimeException('Текст ячейки превышает лимит Excel. Сократите состав отчёта.'); }
|
||||
}
|
||||
|
||||
$sheet->setCellValueExplicitByColumnAndRow($column, $number, $value, $type);
|
||||
}
|
||||
};
|
||||
$write(1, $headers);
|
||||
$number = 1;
|
||||
foreach ($rows as $row) {
|
||||
if ($number > self::MAX_ROWS) { throw new \RuntimeException('В XLSX больше 5000 строк. Сократите период или список категорий.'); }
|
||||
$write(++$number, $row);
|
||||
}
|
||||
|
||||
$last = \PHPExcel_Cell::stringFromColumnIndex(count($headers) - 1);
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter('A1:' . $last . $number);
|
||||
$sheet->getStyle('A1:' . $last . $number)->getAlignment()->setWrapText(true)->setVertical(\PHPExcel_Style_Alignment::VERTICAL_TOP);
|
||||
$sheet->getStyle('A1:' . $last . '1')->getFont()->setBold(true)->getColor()->setRGB('FFFFFF');
|
||||
$sheet->getStyle('A1:' . $last . '1')->getFill()->setFillType(\PHPExcel_Style_Fill::FILL_SOLID)->getStartColor()->setRGB('2563EB');
|
||||
foreach ($headers as $column => $unused) { $sheet->getColumnDimension(\PHPExcel_Cell::stringFromColumnIndex($column))->setWidth(28); }
|
||||
}
|
||||
|
||||
public static function download($filename, $title, array $headers, iterable $rows, array $parameters = array())
|
||||
{
|
||||
$stream = tmpfile();
|
||||
if ($stream === false) { throw new \RuntimeException('Не удалось создать временный XLSX.'); }
|
||||
try {
|
||||
$meta = stream_get_meta_data($stream);
|
||||
self::save($meta['uri'], $title, $headers, $rows, $parameters);
|
||||
$filename = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename((string) $filename));
|
||||
if (function_exists('apache_setenv')) { @apache_setenv('PUBLIC_NO_CACHE', '1'); }
|
||||
$_SERVER['PUBLIC_NO_CACHE'] = '1';
|
||||
Request::setHeader('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', true);
|
||||
Request::setHeader('Content-Disposition: attachment; filename="' . $filename . '"', true);
|
||||
Request::setHeader('Cache-Control: no-store, private, max-age=0', true);
|
||||
Request::setHeader('Pragma: no-cache', true);
|
||||
Request::setHeader('X-Content-Type-Options: nosniff', true);
|
||||
// ZipArchive may replace the file inode; the original tmpfile handle stays empty.
|
||||
readfile($meta['uri']);
|
||||
} finally { fclose($stream); }
|
||||
Request::shutDown();
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -17,5 +17,5 @@
|
||||
return array(
|
||||
'product' => 'AVE.cms',
|
||||
'version' => '3.3',
|
||||
'build' => '0.42',
|
||||
'build' => '0.43',
|
||||
);
|
||||
|
||||
@@ -768,7 +768,7 @@ class PHPExcel_Writer_Excel2007_Worksheet extends PHPExcel_Writer_Excel2007_Writ
|
||||
$objWriter->writeAttribute('ref', str_replace('$', '', $range));
|
||||
|
||||
$columns = $pSheet->getAutoFilter()->getColumns();
|
||||
if (count($columns > 0)) {
|
||||
if (count($columns) > 0) {
|
||||
foreach ($columns as $columnID => $column) {
|
||||
$rules = $column->getRules();
|
||||
if (count($rules) > 0) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
Reference in New Issue
Block a user