Adding an Admin Page

4 vues Markdown

Give a module its own page in the admin panel with two files. An area class returns the HTML; one line in router.php registers it.

Overview

A module that manages its own records or runs a bulk job needs a real page. That page does not belong in the core: you declare a class and register it, and the route, menu entry, privilege check, breadcrumb and theme shell come for free. New to modules? See Your First Module.

The dispatcher is coremio/controllers/admin/module-page.php; slug routes resolve to it and the result goes to the addon theme wrapper.

Structure

classes/ModuleAdminArea.php The base class and registry: register(), route wiring, menu hook, instance helpers.
admin/module-page.php The internal dispatcher: resolves page_* and op_*, invisible in the URL.
tools/addons-area.php The theme wrapper: title bar, buttons, plugins, styles, scripts, modals.
{module}/AdminArea.php + router.php The two files you write. Router::loadModuleRouters() reads the router file before any route is matched.

Walkthrough

Declare the Area Class

  1. Create AdminArea.php in your module directory, namespaced WISECP\Modules\{Type}\{Name}.
  2. Extend \ModuleAdminArea and return a manifest from the static manifest() method.
  3. Override available() when the page only makes sense under a condition; it guards the menu entry too.

Register It

  1. Create router.php next to it: include the class file, then call \ModuleAdminArea::register(AdminArea::class).
  2. Nothing else belongs there. Registration adds three routes, stores the manifest and hooks the menu entry.

Write a Page Method

  1. page_home() answers the area root; every method takes the trailing URL segments as an array.
  2. Build the HTML with heredoc; read your strings from $this->lang.
  3. Point links at yourself with $this->link(), never a hand-written path.

Write an Operation

  1. Name the method op_{name}; only that prefix is callable.
  2. Post to the area URL with an operation parameter; a thrown exception becomes the standard JSON error.
  3. Start with $operation->demo(), read input through Filter::init(), finish with $operation->output().

Store Area Settings

  1. Use settings(), setting() and save_settings(), not a file of your own.
  2. save_settings() merges into the current values, so a partial save keeps the rest, then fires action:module.area_settings_saved.

Reference

Base Class API

exact signatures
class ModuleAdminArea
{
    public string $module_type = '';   // resolved from the namespace
    public string $module_name = '';   // resolved from the namespace
    public array  $lang        = [];   // {module}/lang/{selected}.php, falling back to en.php

    public function __construct();

    // You override this one. Anything else with a default is optional.
    public static function manifest(): array;

    public static function register(string $areaClass): void;
    public static function get(string $type, string $name): ?array;

    public function available(): bool;                                  // default true
    public function slug(): string;
    public function link(array $params = []): string;
    public function module_dir(): string;                               // filesystem path, trailing separator
    public function module_url(): string;                               // public URL of the module directory

    public function settings(): array;
    public function setting(string $key, mixed $default = null): mixed;
    public function save_settings(array $values): void;

    protected function license_state_badge(string $slug): string;
}
Eleven module types, not sixteen

The accepted types are Servers, Payment, Registrars, Product, Addons, SMS, Mail, Authentication, Pipe, Imports and Fraud. For anything else register() does nothing, silently.

What the Manifest Accepts

title Page and browser title; the menu label when menu.name is absent.
slug The URL segment, default the lowercased module name. Filter::route() keeps a-zA-Z0-9, hyphen, underscore and dot.
privileges Privilege keys, checked once for the whole area. Empty means no check.
menu ['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Hetzner Cloud']. The path walks down the tree; omit it and no entry is created.
type, name, class Written by register() from the namespace and the argument; do not set them.

URL Scheme and Method Resolution

the three routes
// Three routes, most specific first. $target is module-page/{Type}/{Name}.
$router->add($slug . '-2', $slug . '/(?)/(?)', $target . '/(1)/(2)');
$router->add($slug . '-1', $slug . '/(?)',     $target . '/(1)');
$router->add($slug,        $slug,              $target);

// Which is why link() derives the route key from the parameter count:
//   $this->link()                     -> /{admin}/{slug}
//   $this->link(['configuration'])    -> /{admin}/{slug}/configuration
//   $this->link(['logs', 'archive'])  -> /{admin}/{slug}/logs/archive
RequestMethod calledArgument
GET /{admin}/{slug}page_home()empty array
GET /{admin}/{slug}/configurationpage_configuration()['configuration']
GET /{admin}/{slug}/logs/archivepage_logs_archive(), else page_logs()['logs', 'archive']
POST with operation=sync_pricesop_sync_prices(Operation $operation)the Operation
no matching method404 pagenothing appears

Hyphens, dots and commas in a segment become underscores: /{slug}/price-list resolves to page_price_list(). Operation names too.

What a Page Method May Return

A string becomes the page body; an array passes the keys below to the wrapper.

KeyTypeEffect
contentstringThe page body. Empty shows a No module area content is available. alert.
page_titlestringOverrides the manifest title.
page_title_buttonsarrayA list of button descriptors; plain HTML shows nothing.
page_title_afterstringFree HTML after the title and buttons.
page_title_logostringAn image URL, or raw HTML when it contains <.
content_layoutstringpanel by default; plain drops the panel.
content_data_classstringExtra class on the content wrapper (plain layout).
breadcrumbsarrayAppended to the Dashboard + area title trail.
pluginsarrayMerged onto the wrapper defaults.
page_styles, page_scripts, modalsstringHead, footer and modal area.

The Signed-in Administrator

signature
public static function LoginData($type = 'member', $isRemembered = false, $recheck = false);
in an area method
$adminId = (int) (\UserManager::LoginData('admin')['id'] ?? 0);   // 0 outside the panel (CLI, cron)

// The operator picker for an assignment field.
$staff = \Admin::list();          // [id => ['id' => 1, 'full_name' => 'Jane Doe'], ...]

Example

A two-file area for an imaginary Acme module.

Acme/AdminArea.php
<?php
namespace WISECP\Modules\Servers\Acme;

use AdminFormBuilder;
use Exception;
use Filter;
use Operation;
use User;
use UserManager;
use WDB;

class AdminArea extends \ModuleAdminArea
{
    public static function manifest(): array
    {
        return [
            'title'      => 'Acme',
            // 'slug'    => 'acme',                  // default: strtolower(module name)
            'privileges' => ['PRODUCTS_OPERATION'],
            'menu'       => ['path' => ['PRODUCTS', 'GROUP_HOSTING_SERVER'], 'name' => 'Acme'],
        ];
    }

    /** Hides the menu entry and 404s the page while no Acme server exists. */
    public function available(): bool
    {
        static $available = null;

        if ($available === null)
            $available = (bool) WDB::select('id')->from('servers')
                ->where('type', '=', 'Acme', '&&')
                ->where('status', '=', 'active')
                ->build();

        return $available;
    }

    public function page_home(array $params): array
    {
        $L    = $this->lang;
        $conf = $this->settings();

        $form = new AdminFormBuilder('acmeAreaForm', $this->link(), ['disableStickySubmit' => true]);
        $form->addHidden('operation', 'save_settings');
        $form->addAmount('profit_rate', $L['profit-rate'] ?? '', (string) ($conf['profit_rate'] ?? 25));
        $form->addSwitch('auto_sync', $L['auto-sync'] ?? '', $L['auto-sync-desc'] ?? '', '1', (int) ($conf['auto_sync'] ?? 0) === 1);

        return [
            'content'          => $form->render(),
            'page_title'       => $L['area-title'] ?? 'Acme',
            'page_title_after' => $this->license_state_badge('acme'),
        ];
    }

    public function op_save_settings(Operation $operation): bool
    {
        $operation->demo();

        $values = [
            'profit_rate' => (float) Filter::init('POST/profit_rate', 'amount'),
            'auto_sync'   => (int) Filter::init('POST/auto_sync', 'rnumbers') === 1,
        ];

        if ($values['profit_rate'] < 0)
            throw new Exception($this->lang['err-negative-rate'] ?? 'The profit rate cannot be negative.');

        $this->save_settings($values);

        $adminId = (int) (UserManager::LoginData('admin')['id'] ?? 0);
        User::addAction($adminId, 'update', 'acme-settings-updated', ['changes' => $values]);

        return $operation->output([
            'status'   => 'successful',
            'redirect' => 'reload',
        ]);
    }
}
Acme/router.php
<?php
namespace WISECP\Modules\Servers\Acme;

include_once __DIR__ . DS . 'AdminArea.php';

\ModuleAdminArea::register(AdminArea::class);

The dispatcher decides whether your method is reached at all:

module-page.php
$area_info = ModuleAdminArea::get($type, $name);
if (!$area_info) return $this->page_404();

if (($area_info['privileges'] ?? []) && !Admin::isPrivilege($area_info['privileges'])) return 'Access Denied';

$area = new $area_info['class']();
if (!$area->available()) return $this->page_404();

if ($operation = Filter::init('REQUEST/operation')) return $this->area_operation($area, $operation);

$page    = Filter::route($this->params[2] ?? '') ?: 'home';
$subpage = Filter::route($this->params[3] ?? '');

$filter_method = fn ($param) => str_replace(['-', '.', ','], '_', $param);
$method        = $filter_method('page_' . $page);            // page_logs
$method2       = $filter_method($method . '_' . $subpage);   // page_logs_archive

$page_params = array_slice($this->params, 2);

if ($subpage && method_exists($area, $method2)) $result = $area->$method2($page_params);
elseif (method_exists($area, $method))          $result = $area->$method($page_params);
else return $this->page_404();

if (!is_array($result)) $result = ['content' => (string) $result];

Pitfalls

A page method that ends silently is a fatal, not a routing problem

An undefined property or a forgotten import produces no output, and try/catch on Exception will not catch it because an Error is not an Exception. The commonest case is Admin::$data['id']: that static property does not exist, so read the operator with UserManager::LoginData('admin').

A slug that collides with a core route breaks link generation

Real controller files win at dispatch, so an area registered as services is unreachable and overwrites that key for every link. Pick a slug no core route uses.

A missing parent hides the menu entry without a word

The menu hook walks down the tree and returns as soon as a step is absent. If the parent was trimmed by the administrator's privileges your entry is not added, while the page stays reachable by URL.

Operations are blocked while the licence is not active

The area dispatcher checks the licence before it looks for your method and answers with a JSON error. Pages are affected too, at the view layer.

Area settings live under the module name

The row key in the configurations table is the module name as the directory spells it, so renaming the directory orphans the saved settings. Migrate by overriding settings().

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.