Плюшка CMS#29.08.2018

/* Создан для быстрой разработки приложений */
Скачать Демо

Быстрый старт

В этой статье описывается процесс разработки несложного модуля "от и до".

Описание модуля:

Каталог сотрудников фирмы. На сайте отображается список сотрудников с разбивкой по страницам (пагинация). При клике на ФИО сотрудника открывается вложенная страница, на которой отображается развёрнутая информация о нём. Модуль имеет админку, в которой настраиваются мета-теги и количество сотрудников на странице.

Модуль будет иметь такие ЧПУ-ссылки (их можно изменить при помощи системы подмена ссылок):

Итак, поехали...

1. Снимок состояния системы.

Прежде всего нужно сделать снимок состояния системы - это нужно для того, чтобы отследить все вносимые изменения, чтобы потом проще было собрать переносимый модуль. Если создание переносимого модуля не требуется, то этот шаг можно не выполнять.

Модуль devTools должен быть установлен (в скачиваемом дистрибутиве этот модуль уже установлен).

После авторизации с правами суперпользователя, в верхнем левом углу нажимаем на кнопку "Инструменты разработчика", далее переходим на вкладку "Снимок движка" и нажимаем кнопку "Сделать снимок":

 

2.Создание таблиц базы данных

Требуется всего одна таблица, но если планируется сделать модуль переносимым, то необходимо создать её в обоих СУБД. Вот структура для MySQL, обратите внимание, что дата всегда должна храниться в формате UNIX TIMESTAMP:

CREATE TABLE employee (
  id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT,
  fio CHAR(50) NOT NULL,
  birthday INT UNSIGNED NOT NULL,
  photo CHAR(10),
  position CHAR(50) NOT NULL,
  about VARCHAR(1000)
)

3. Создание конфигурационного файла

Модуль должен хранить следующие параметры:

Эти данные удобно хранить в конфигурационном файле. В директории /config/ нужно создать файл с таким содержимым:

<?php return array(
  'onPage'=>5,
  'metaTitle'=>'',
  'metaDescription'=>'',
  'metaKeyword'=>''
);

В последствии этот конфигурационный файл будет редактироваться через админку.

4. Общедоступная часть модуля: MVC-контроллер

Сначала немного терминологии:

Контроллер - это класс, находящийся в отдельном файле в директории /controller;
имя контроллера - это имя этого файла;
действие - это метод контроллера, начинающийся с ключевого слова "action". Каждой логической странице сайта соответствует своё действие;
submit-действие - это метод контроллера, выполняющий обработку POST-данных, поступающих от HTML-форм. Обычно submit-действию логически соответствует действие.

Движок воспринимает ЧПУ-ссылки следующим образом:

example.com/ИМЯ_КОНТРОЛЛЕРА/ИМЯ_ДЕЙСТВИЯ

Например, такая ссылка: example.com/employee приводит к контроллеру /controller/employee.php и методу actionIndex(). В ссылке нет ИМЯ_ДЕЙСТВИЯ,  поэтому используется "Index". Заметим, что ИМЯ_ДЕЙСТВИЯ может быть переопределено в конструкторе контроллера в зависимости от запрошенного URL.

Для упрощения задачи мы поступим плохо и всю логику разместим непосредственно в контроллере, а MVC-модели не будет вообще. Вот код контрллера:

class sController extends controller {

  function __construct() {
    parent::__construct();
    if(is_numeric($this->url[1])) { //если в ссылке два параметра, то перенаправить на actionItem
      $this->id=(int)$this->url[1];
      $this->url[1]='Item';
    }
    core::language('employee'); //файл локализации /language/ru.employee.php (для русского языка)
  }

  //Страница со списком сотрудников
  public function actionIndex() {
    $db=core::db(); //драйвер базы данных
    $cfg=core::config('employee'); //конфигурация из /config/employee.php
    $this->onPage=$cfg['onPage']; //для пагинации
    $db->query('SELECT id,fio,photo,position FROM employee ORDER BY fio',$cfg['onPage']);
    $data=array();
    $link=core::link('employee/');
    while($item=$db->fetchAssoc()) {
      if(!$item['photo']) $item['photo']='nophoto.gif';
      $item['photo']=core::url().'public/employee/'.$item['photo'];
      $item['link']=$link.$item['id'];
      $data[]=$item;
    }
    $this->count=$db->foundRows(); //полное количество сотрудников
    $this->data=$data;
    $this->pageTitle=LNGEmployee; //заголовок в теге H1
    $this->metaTitle=$cfg['metaTitle']; //тег TITLE
    $this->metaDescription=$cfg['metaDescription'];
    $this->metaKeyword=$cfg['metaKeyword'];
    $this->style('employee'); //подключить файл CSS /public/css/employee.css
    return 'Index'; //MVC-представление, в данном случае это HTML-файл /view/employeeIndex.php
  }

  //Страница развёрнутой информации о сотруднике
  public function actionItem() {
    $db=core::db();
    $this->data=$db->fetchArrayOnceAssoc('SELECT * FROM employee WHERE id='.$this->id);
    if(!$this->data) core::error404(); //404-я ошибка, если сотрудник не найден в базе данных
    if($this->data['photo']) $this->data['photo']=core::url().'public/employee/'.$this->data['photo'];
    else $this->data['photo']=core::url().'public/employee/nophoto.gif';
    $this->data['age']=ceil((time()-$this->data['birthday'])/365/86400);
    $this->pageTitle=$this->data['fio'];
    $this->metaTitle=$this->data['position'].' '.$this->data['fio'];
    $this->style('employee');
    return 'Item';
  }

  //Хлебные крошки
  public function breadcrumbItem() {
    return array('<a href="'.core::link('employee').'">'.LNGEmployee.'</a>');
  }
}

Переменная $this->url содержит массив, содержащий ЧПУ-ссылку на текущую страницу. Для example.com/employee это: array('employee','Index'). Для страницы example.com/employee/15 это: array('employee','15').

Имя действия, должно содержаться в $this->url[1]. Для страницы example.com/employee будет вызван метод actionIndex(), но для страницы example.com/employee/15 (страница развёрнутой информации о сотруднике) - метод action15(). Это неправильно, поэтому в конструкторе контроллера идентификатор сотрудника переносится в $this->id, а в $this->url[1] помещается правильное имя действия. Таким образом реализуется роутер.

Также в конструкторе подключается файл локализации (/language/XX.employee.php). Файл локализации содержит объявления PHP-констрант, начинающихся с приставки LNG.

В методе actionIndex() осуществляется подготовка данных для отображения страницы. Обратите внимание на эту строку:

$link=core::link('employee/');

Функция core::link() возвращает построенную ЧПУ-ссылку (в данном случае это "/employee"). Таким образом нужно "оборачивать" все ссылки - без этого не будет работать система подмены ссылок (она позволяет произвольно менять любые ссылки). Кроме того core::link() также добавляет к ссылке псевдоним языка, если это требуется (для мультиязычных сайтов).

В $db->query() во втором параметре передаётся количество элементов на странице. Если этот параметр задан, то CMS автоматически добавит к запросу LIMIT, а номер текущей странцы возьмёт из $_GET['page']. Получить общее количество строк можно вызвав метод foundRows().

Действие должно вернуть (return ...) MVC-представление. Если это класс, то движок вызовет его метод render(), если это строка, то будет подключен соответствующий файл, находящийся в директории /view/. В данном случае действие возвращает "Index", поэтому в нужный момент движок подключитфайл /view/employeeIndex.php.

По умолчанию CMS генерирует хлебные крошки в таком виде: "Главная > НАЗВАНИЕ_СТРАНИЦЫ". Название страницы - это $this->pageTitle. Чтобы изменить такое поведение нужно для соответствующего действия создать функцию breadcrumbИМЯ_ДЕЙСТВИЯ(). Эта функция должна вернуть массив, состоящий из ссылок на недостающие элементы между "Главная" и "НАЗВАНИЕ_СТРАНИЦЫ".

5. Общедоступная часть модуля: MVC-представление

Вот содержимое файла /view/employeeIndex.php:

<?php foreach($this->data as $item) { ?>
  <div class="employeeItem">
  <?php $this->admin($item); ?>
  <a href="<?=$item['link']?>"
    <img src="<?=$item['photo']?>" alt="<?=$item['fio']?>" />
    <p class="title"><?=$item['fio']?></p>
  </a>
  Должность: <?=$item['position']?>
  <div style="clear:both;"></div>
  </div>
<?php }
core::widget('pagination',array('limit'=>$this->onPage,'count'=>$this->count));

Через переменную $this из представления можно получить доступ к контроллеру. Вызов $this->admin($item) добавляет кнопки административного интерфейса для каждого сотрудника - об этом позже. В самом конце выводится виджет пагинации.

Реализация представления /view/employeeItem.php тривиальна:

<img src="<?=$this->data['photo']?>" alt="<?=$this->data['fio']?>" class="employee" />
<p>Должность: <b><?=$this->data['position']?></b></p>
<p>Возраст: <b><?=$this->data['age']?></b></p>
<div style="clear:both;"></div>
<?=$this->data['about']?>

 

Вот в общем-то и вся реализация общедоступной части модуля (в коде содержится важный недостаток: в конфигурационном файле (/config/employee.php) мета-данные редактируются только для одного языка.

6. Админка: создание типа пункта меню

Если нет требования создания переносимого модуля, то этот этап можно пропустить. Если же в меня на сайте нужно добавить ссылку на страницу создаваемого модуля, то можно выбрать тип пункта меню "произвольная ссылка", а в поле Ссылка вписать "employee". Технически меню - это просто ссылки, не имеющие никакх метаданных.

Но для переносимых модулей такое неудобно и нужно зарегистрировать свой тип меню, поэтому нужно выполнить такой SQL-запрос:

INSERT INTO menuType SET title="Сотрудники", controller="employee", action="menuList"

Этот запрос нужно выполнить для обоих СУБД или же для одной, а позже, при помощи инструмента devTool, скопировать все таблицы из одной базы данных в другую.

После выполнения этого запроса, при добавлении пункта меню, появится тип "Сотрудники":

 

Два поля таблицы menuTypecontroller и action задают имя MVC-контроллера админки и действие, которое будет обрабатывать запрос добавления или редактирования пункта меню. Контроллер - это файл в директории /admin/controller (/admin/controller/employee.php), а действие - это процедура этого контроллера (actionMenuList).

Действие должно сформировать HTML-форму, запрашивающую какие-либо дополнительные параметры, а submit-действие (процедура, которая обрабатывает POST-данные HTML-формы) должно вернуть (return ...;) относительную ссылку на созданную страницу сайта.

7. Админка: создание набора прав доступа

Если в разграничении прав доступа администраторов нет необходимости, то этот этап можно пропустить. Для создания "права" нужно выполнить SQL-запрос, вот вариант для MySQL:

INSERT INTO userRight SET module="employee.*", description="Сотрудники";

Теперь, если в левом верхнем углу любой страницы сайта нажать кнпоку "управление группами пользователей", то там будет отображаться созданное "право".

8. Админка: проверка прав и обработка добавления пункта меню

Админка всего модуля может быть реализована в одном контроллере (/admin/controller/employee.php). Для целей проверки прав доступа в этом контроллере предусмотрена специальная функция right, которая должна вернуть массив, где для каждого действия указывается одно или несколько "прав доступа". Если пользователь относится к группе с номером 255, то функция right не будет вызвана вовсе.

В случае созадаваемого модуля, предусмотрено только одно "право": employee.* - тут employee - это имя контроллера, а звёздочка обозначает любые действия, связанные с этим контроллером (на самом деле это обозначение условное).

Как уже было сказано, при создании пункта меню, запрос будет обрабатывать контроллер employee и действие actionMenuList. Это действие должно сгенерировать HTML-форму, запрашивающую необходимые дополнительные параметры, затем данные с этой формы будут переданы submit-действию, которое должно на основе полученных данных вернуть (оператором return) относительную ссылку на создаваемую страницу. В этом модуле запрашивать какие-либо дополнительные параметры не требуется, поэтому реализация контроллера проста:

class sController extends controller {

  public function right($right,$action) {
    return array(
      'MenuList'=>'employee.*'
    );
  }

  public function actionMenuList() {
    $form=core::form(); //Экземпляр класса form - генератор HTML-форм
    $form->submit('Продолжить','submit'); //добавить кнопку "отправить", второй параметр - это значение тега "name" - требуется чтобы формы не была "пустой"
    return $form; //класс form может быть использован в роли MVC-представления
  }

  public function actionMenuListSubmit($data) { //$data содержит POST-данные
    return 'employee'; //это относительная ссылка
  }

} ?>

Итак, добавление и удаление пунктов меню реализовано.

9. Админка: кнопки управления сотрудниками и кнопка "настройки"

Кнопки админки - это накладываемые поверх контента сайта небольшие пиктограммы, при клике на которых открываются всплывающие диалоговые окна, предоставляющие возможность редактировать те или иные элементы сайта. Содержимое всплывающих окон - это фрейм, загружаемый по такому URL-адресу: example.com/admin/index2.php?controller=ИМЯ_КОНТРОЛЛЕРА&action=&ИМЯ_ДЕЙСТВИЯ&ДОПОЛНИТЕЛЬНЫЕ_ПАРАМЕТРЫ&_lang=ТЕКУЩИЙ_ЯЗЫК_САЙТА&_front.

Для создаваемого модуля потребуются такие кнопки (также приведена ссылка соответствующей страницы админки, открываемой во фрейме):

Реализация этих кнопок должна находитсья в контроллере общедоступной части сайта (/controller/employee.php). Предусмотрены две функции, которые могут быть объявлены в контроллере общедоступной части сайта:

adminИМЯ_ДЕЙСТВИЯLink() и adminИМЯ_ДЕЙСТВИЯLink2(mixed $data).

Первая используется для генерации кнопок, которые располагаются в верхнем левом углу ОСНОВНОГО контента сайта, эти кнопки выводятся один раз на странице. Вторая - для генерации кнопок для каждого из элементов какого-либо списка. Например, на странице со списком сотрудников первая функция будет выводить кнопку "настройки", а вторая - кнопки "редактировать" и "удалить" для кадого сотрудника.

В пункте "5. Общедоступная часть модуля: MVC-представление" была такая строка:

<?php $this->admin($item); ?>

Этот вызов приводит к созданию кнопок при помощи второго метода (adminIndexLink2).

Оба эти метода должны вернуть массив, содержащий правила для создания кнопок. К контроллеру нужно добавить такие функции:

//Кнопки админки
public function adminIndexLink() {
  return array(
    array('employee.*','?controller=employee&action=setting','setting','Настройки, мета-теги'),
    array('employee.*','?controller=employee&action=item','new','Добавить сотрудника')
  );
}

public function adminIndexLink2($data) {
  return array(
    array('employee.*','?controller=employee&action=item&id='.$data['id'],'edit','Редактировать'),
    array('employee.*','?controller=employee&action=delete&id='.$data['id'],'delete','Удалить',null,'if(!confirm(\'Подтвердите удаление.\')) return false;')
  );
}

public function adminItemLink() {
  return array(
    array('employee.*','?controller=employee&action=item&id='.$this->data['id'],'edit','Редактировать'),
    array('employee.*','?controller=employee&action=delete&id='.$this->data['id'],'delete','Удалить',null,'if(!confirm(\'Подтвердите удаление.\')) return false;')
  );
}

Теперь на странице example.com/employee будут отображаться все необходимые кнопки админки, правда при нажатии на них будет генерироваться 404-я ошибка.

10. Админка: реализация обработчиков кнопок управления контентом модуля

В контроллер админки (/admin/controller/employee.php) нужно добавить следующий код:

//Настройки
public function actionSetting() {
  $cfg=core::config('employee'); //конфигурация из /congif/employee.php
  $form=core::form(); //экземпляр класса form - генератор HTML-форм
  $form->text('onPage','Элементов на странице',$cfg['onPage']);
  $form->text('metaTitle','META Заголовок',$cfg['metaTitle']);
  $form->text('metaDescription','META Описание',$cfg['metaDescription']);
  $form->text('metaKeyword','META Ключевые слова',$cfg['metaKeyword']);
  $form->submit();
  return $form;
}

public function actionSettingSubmit($data) { //submit-действие, $data содержит POST-данные HTML-формы
  core::import('admin/core/config'); //класс model - редактирование конфигурационных файлов
  $cfg=new config();
  $cfg->onPage=(int)$data['onPage'];
  $cfg->metaTitle=$data['metaTitle'];
  $cfg->metaDescription=$data['metaDescription'];
  $cfg->metaKeyword=$data['metaKeyword'];
  $cfg->save('employee'); //сохранить в файл /config/employee.php
  core::redirect('?controller=employee&action=setting','Изменения сохранены');
}

//создание/редактирование сотрудника
public function actionItem() {
  if(isset($_REQUEST['id'])) { //есть id - редактирвание сотрудника
    $db=core::db();
    $data=$db->fetchArrayOnceAssoc('SELECT * FROM employee WHERE id='.(int)$_GET['id']);
    if(!$data) core::error404();
  } else { //подготовить данные для нового сотрудника
    $data=array('id'=>null,'fio'=>'','birthday'=>null,'position'=>'','about'=>'','photo'=>null);
  }
  $form=core::form(); //экземпляр класса form - генератор HTML-форм
  $form->hidden('id',$data['id']); //добавить скрытое поле "id"
  $form->text('fio','ФИО',$data['fio']); //добавить текстовое поле
  $form->text('position','Должность',$data['position']);
  $form->date('birthday','Дата рождения',$data['birthday']); //поле выбора даты
  $form->editor('about','Дополнительная информация',$data['about']); //WISIWING-редактор
  $form->file('photo','Фото');
  $form->submit(); //кнопка "отправить"
  $this->cite='Дата рождения не будет отображаться на сайте.'; //это просто текстовая подсказка, отображаемая внизу окошка
  return $form; //класс form может быть MVC-представлением
}

public function actionItemSubmit($data) {
  //Выполнить проверку загруженного файла до выполнения SQL-запроса
  if($data['photo']['size']) {
    core::import('core/picture'); //класс picture для обработки изображений
    $picture=new picture($data['photo']); //в конструктор можно передвать как массив, так и имя файла
    if(controller::error()) return false; //Файл не является изображением? Текст сообщения об ошибке установит класс picture
  }
  //Выполнить запрос INSERT или UPDATE
  $model=core::model('employee'); //экземпляр класса model - универсальная модель
  $model->set($data); //передача POST-данных
  if(!$model->save(array( //валидация и сохранение данных
    'id'=>array('primary'),
    'fio'=>array('string','ФИО',true,'min'=>8,'max'=>50),
    'position'=>array('string','должность',true,'max'=>50),
    'about'=>array('html'),
    'birthday'=>array('date','дата рождения')
  ))) return false;
  if($data['photo']['size']) { //если загружена фотография
    //Удалить старое изображение
    $db=core::db();
    if($data['id']) { //если есть id, то это редактирование, а не создание
      $fname=$db->fetchValue('SELECT photo FROM employee WHERE id='.$data['id']);
      if($fname) $fname=core::path().'public/employee/'.$fname;
      if($fname && file_exists($fname)) unlink($fname);
    }
    $picture->resize(300); //ширина - 300px, высота - пропорционально исходной
    $f=$picture->save('public/employee/'.$model->id,100); //возвращает имя файла без пути, расширение файла будет определено исходя из типа загруженного в конструкторе изображения
    $db->query('UPDATE employee SET photo='.$db->escape($f).' WHERE id='.$model->id);
  }
  core::redirect('?controller=employee&action=item&id='.$model->id,'Изменения сохранены');
}

//Удаление сотрудника
public function actionDelete() {
  $model=core::model('employee');
  $model->loadById($_GET['id'],'id,photo'); //загрузить поля "id" и "photo" по первичному ключу
  //Удалить фото, если есть
  if($model->photo) {
    $f=core::path().'public/employee/'.$model->photo;
    if(file_exists($f)) unlink($f);
  }
  $model->delete();
  core::redirect('','Выполнено');
}

Смысл методов actionSetting(), actionSettingSubmit(), actionItem() и actionDelete() хорошо понятен из комментариев, поэтому подробнее только о actionItemSubmit().

В этом методе используется класс picture, который производит обработку изображений (изменение размеров, наложение водного знака, преобразование в другие форматы).

В $data['photo'] содержится информация о загруженном файле - она скопирована из $_FILES. Если файл загружен, то сначала проверяется его формат - это нужно сделать до того, как будет выполнен SQL-запрос INSERT или UPDATE. Если submit-действе не выполнило редирект, то будет повтроно выполнено действие (actionItem), а на экран выведено сообщение об ошибке (если задано).

Далее при помощи класса model выполняется валидация данных HTML-формы и сохранение (INSERT или UPDATE в зависимости от того, задано ли значение первичного ключа (id).

Затем старое фото удаляется (если оно есть), изменяются размеры загруженного изображения и обновляется информация в базе данных. В данном случае вместо выполнения двух SQL-запросов можно было бы сгенирировать имя файла изображения на основе ФИО сотрудника (для этих целей есть core::translate).

Всё. На этом модуль полностью готов.

11. Корректная установка модуля

Чтобы создать переносимый модуль, необходимо "рассказать" движку какие файлы и таблицы базы данных относится к созданному модулю. Тогда появится возможность корректно удалить модуль при необходимости, а также для того, чтобы можно было экспортировать модуль в ZIP-архив.

Для начала нужно зарегистрировать модуль в системе, для этого в файл /admin/config/_module.php нужно добавить строки:

'employee'=>array(
  'name'=>'Демо-модуль СОТРУДНИКИ',
  'version'=>'1.0',
  'status'=>100,
  'url'=>'http://plushka-cms.ru'
),

Тут status - это состояние модуля, всегда "100". Теперь в админке модуль появится в разделе "модули", но для удаления или экспорта модуля этой информации недостаточно.

В самом начале был создан снимок состояния системы. Пришла пора воспользваться этой информцией. В левом верхнем углу любой старницы сайта нужно нажать на "Инструменты разработчика", далее перейти на вкладку "Снимок движка" и нажать кнопку "Сравнить".

Система расскажет о внесённых изменениях, а также сгенерирует шаблон модуля - это специальный конфигурационный файл, в котором содержится информация о "составе" модуля:

 

Шаблон модуля нужно сохранить в файл /admin/module/employee.php ("depend" нужно удалить).

На этом установка модуля завершена и можно удалить снимок движка (это файл /admin/data/devTool-image.php).

12. Экспорт модуля

В верхнем левом углу любой страницы сайта нужно нажать кнопку "Инструменты разработчика", далее перейти на вкладку "Модуль", выбрать из списка нужный и нажать кнопку "Экспорт модуля в директорий /tmp". Теперь осталось только положить в архив содержимое директория /tmp.

Примечание: на самом деле файлы локализации (директорий /language/) нужно помещать в отдельный модуль, чтобы для каждого языка был отдельный модуль, ведь и правда: зачем устанавливать английский язык, если сайт только на русском?