跳转到主要内容

dhtmlxScheduler 与 PHP:Slim

本教程为您提供了使用 Slim 4 框架结合服务器端 REST API 构建基于 PHP 的 Scheduler 的基本步骤。

注释

本教程使用 Slim Framework v4.x。 如果您正在使用较早的版本,请参考 Slim Framework v3.x 指南。

此外,还有其他平台和框架集成的教程可供参考:

在开发 PHP 应用时,通常会采用现有框架,而不是从零开始搭建所有内容。

本指南将使用 Slim 4 框架以及服务器端的 REST API,数据存储采用 MySQL。CRUD 操作通过 PDO 实现,并设计为可灵活应用于其他框架。

现成的演示已在 GitHub 提供 供您参考。请按照以下步骤创建类似的应用。

注释

完整源码可在 GitHub 获取。

步骤 1. 初始化项目

创建项目

首先,使用 Slim 4 框架的 skeleton application

通过 Composer 创建应用:

$ composer create-project slim/slim-skeleton scheduler-slim-howto
$ cd scheduler-slim-howto/

步骤 2. 在页面中添加 Scheduler

下一步是在网页中放置一个 scheduler,主要分为两个简单的小步骤。

创建视图

app/templates 目录下创建一个 basic.html 文件:

app/templates/basic.html
<!doctype html>
<html>
<head>
<title> Getting started with dhtmlxScheduler</title>
<meta charSet="utf-8"/>
<script src="https://cdn.dhtmlx.com/scheduler/edge/dhtmlxscheduler.js"></script>
<link href="https://cdn.dhtmlx.com/scheduler/edge/dhtmlxscheduler.css"
rel="stylesheet" type="text/css" charSet="utf-8">
<style>
html, body{
margin:0px;
padding:0px;
height:100%;
overflow:hidden;
}
</style>
</head>
<body>
<div id="scheduler_here" className="dhx_cal_container">
<div className="dhx_cal_navline">
<div className="dhx_cal_prev_button">&nbsp;</div>
<div className="dhx_cal_next_button">&nbsp;</div>
<div className="dhx_cal_today_button"></div>
<div className="dhx_cal_date"></div>
<div className="dhx_cal_tab" name="day_tab"></div>
<div className="dhx_cal_tab" name="week_tab"></div>
<div className="dhx_cal_tab" name="month_tab"></div>
</div>
<div className="dhx_cal_header"></div>
<div className="dhx_cal_data"></div>
</div>
<script>
scheduler.config.xml_date="%Y-%m-%d %H:%i";
scheduler.init('scheduler_here', new Date(2019,0,20), "week");
scheduler.load("/events");

var dp = scheduler.createDataProcessor("/events");
dp.init(scheduler);
dp.setTransactionMode("REST"); // use to transfer data with REST
</script>
</body>
</html>

设置路由

新页面准备好后,需要通过浏览器访问。请在 app/routes.php 中添加一个路由:

app/routes.php
$app->get('/', function (Request $request, Response $response) {
$payload = file_get_contents('../app/templates/basic.html');
$response->getBody()->write($payload);
return $response;
});

此时,运行应用即可在页面上看到 scheduler:

Scheduler initialization

步骤 3. 准备数据库

scheduler 已经就位,接下来需要设置数据库并将其与应用连接。

创建数据库

可以使用您喜欢的 MySQL 客户端(如 phpMyAdmin)或命令行创建数据库。以下为创建数据库及日历事件表的 SQL:

CREATE DATABASE  IF NOT EXISTS `scheduler`;
USE `scheduler`;

DROP TABLE IF EXISTS `events`;
CREATE TABLE `events` (
`id` int(11) AUTO_INCREMENT,
`start_date` datetime NOT NULL,
`end_date` datetime NOT NULL,
`text` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
) DEFAULT CHARSET="utf8;"

如需通过 MySQL 控制台导入,请将上述 SQL 保存为 dump.sql 文件并运行:

$ mysql -uuser -ppass scheduler < mysql_dump.sql

然后,打开 app/settings.php,添加数据库设置数组,并根据实际情况修改为您的数据库信息:

app/settings.php
'pdo' => [
'engine' => 'mysql',
'host' => 'localhost',
'database' => 'scheduler',
'username' => 'user',
'password' => 'pass',
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',

'options' => [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => true,
],
]

接下来,更新 app/dependencies.php,将 PDO 实例注入到应用容器:

app/dependencies.php
// Inject a new instance of PDO into the container
$containerBuilder->addDefinitions([
PDO::class => function (ContainerInterface $container) {
$config = $container->get('settings')['pdo'];
$dsn = "{$config['engine']}:host="{$config["'host']};dbname="{$config["'database']};
charSet="{$config["'charset']}";
$username = $config['username'];
$password = $config['password'];
return new PDO($dsn, $username, $password, $config['options']);
},
]);

步骤 4. 加载数据

scheduler 已设置为从 "/events" 端点请求事件数据。现在需要为该路由添加处理器以提供实际数据。

由于 scheduler 需要多个处理器,这里使用 Slim 4 的 route groups 进行组织。

打开 app/routes.php,为 "/events" 添加一个带 GET 操作的分组:

app/routes.php
$app->group('/events', function ($group) {
$group->get('', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$queryText = 'SELECT * FROM `events`';
$params = $request->getQueryParams();
$query = $db->prepare($queryText);
$query->execute();
$result = $query->fetchAll();
$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});
});

只要数据库中有事件,它们就会显示在 scheduler 中。

动态加载

此时,scheduler 会在启动时加载所有事件记录。对于数据量较小的场景,这种方式没问题。但如果应用用于计划或预定且不会删除旧数据,数据量会逐渐增大,最终可能导致每次加载页面时都要请求大量数据。

动态加载可以避免这种情况,scheduler 会将当前显示的日期范围作为请求参数发送到服务器,服务器只返回相关的记录。每当用户切换可见日期范围时,scheduler 会获取新的数据子集。

在客户端启用动态加载,只需使用 setLoadMode 方法,参数可为 "day"、"week" 或 "month"。例如:

scheduler.config.xml_date="%Y-%m-%d %H:%i";
scheduler.init("scheduler_here", new Date(2019, 0, 20), "week");
scheduler.setLoadMode("day");
scheduler.load("/events");

在服务器端,可以这样处理:

app/routes.php
$app->group('/events', function ($group) {
$group->get('', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$queryText = 'SELECT * FROM `events`';
$params = $request->getQueryParams(); /*!*/
$queryParams = []; /*!*/
if (isset($params['from']) && isset($params['to'])) { /*!*/
$queryText .= " WHERE `end_date`>=? AND `start_date` < ?;"; /*!*/
$queryParams = [$params['from'], $params['to']]; /*!*/
} /*!*/
$query = $db->prepare($queryText);
$query->execute($queryParams); /*!*/
$result = $query->fetchAll();
$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});
});

步骤 5. 保存更改

实现后端处理程序

此时,调度器已经可以从后端获取数据。下一步是使其能够将更改保存回数据库。

客户端以 REST 模式运行,这意味着它会发送 POST、PUT 和 DELETE 请求来管理事件。 参考请求格式及调度器使用的所有路由

为此,您需要定义一个控制器来处理数据模型上的操作,设置相应的路由,并在客户端启用数据保存功能。

回到 app/routes.php,为 "/events" 组添加一个 POST 请求的处理程序。这将用于插入新事件:

app/routes.php
$group->post('', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$body = $request->getParsedBody();

$queryText = 'INSERT INTO `events` SET
`start_date`=?,
`end_date`=?,
`text`=?';
$queryParams = [
$body['start_date'],
$body['end_date'],
$body['text']
];
$query = $db->prepare($queryText);
$query->execute($queryParams);
$result = [
'tid' => $db->lastInsertId(),
'action' => 'inserted'
];

$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});
注释

当添加新任务时,其 ID 会通过响应对象的 tid 属性返回给客户端。
响应的 JSON 还可以包含其他属性,客户端处理程序可以访问这些属性。

同样,为 PUT 请求添加处理程序:

$group->put('/{id}', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$id = $request->getAttribute('route')->getArgument('id');
parse_str(file_get_contents("php://input"), $body);
$queryText = 'UPDATE `events` SET
`start_date`=?,
`end_date`=?,
`text`=?
WHERE `id`=?';
$queryParams = [
$body['start_date'],
$body['end_date'],
$body['text'],
$id
];

$query = $db->prepare($queryText);
$query->execute($queryParams);

$result = [
'action' => 'updated'
];
$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});

对于 DELETE 请求:

$group->delete('/{id}', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$id = $request->getAttribute('route')->getArgument('id');
$queryText = 'DELETE FROM `events` WHERE `id`=? ;';

$query = $db->prepare($queryText);
$query->execute([$id]);

$result = [
'action' => 'deleted'
];

$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});

启用客户端数据保存

最后,需配置客户端以与刚刚设置的 API 进行交互:

public/basic.phtml

scheduler.config.xml_date="%Y-%m-%d %H:%i";
scheduler.init("scheduler_here", new Date(2019, 0, 20), "week");
scheduler.setLoadMode("day");

// 从后端获取数据
scheduler.load("/events");

// 将更改发送回后端
var dp = scheduler.createDataProcessor("/events"); dp.init(scheduler); /*!*/
// 配置数据交换模式
dp.setTransactionMode("REST"); /*!*/

重启应用后,调度器即可创建、删除和更新事件,并且所有更改在页面刷新后会被保留。

Scheduler CRUD

循环事件

若需支持循环事件(如每日重复),请在调度器页面引入相应扩展:

...
<body>
...
<script>
scheduler.plugins({
recurring: true /*!*/
});
scheduler.config.xml_date="%Y-%m-%d %H:%i";
scheduler.init("scheduler_here", new Date(2019, 0, 20), "week");
...
</script>
</body>

"events" 表需要额外的列来存储循环相关信息。以下 SQL 查询可创建支持循环事件的表:

CREATE DATABASE  IF NOT EXISTS `scheduler`;
USE `scheduler`;

DROP TABLE IF EXISTS `events`;
CREATE TABLE `events` (
`id` int(11) AUTO_INCREMENT,
`start_date` datetime NOT NULL,
`end_date` datetime NOT NULL,
`text` varchar(255) DEFAULT NULL,
`event_pid` int(11) DEFAULT 0,
`event_length` bigint(20) unsigned DEFAULT 0,
`rec_type` varchar(25) DEFAULT '',
PRIMARY KEY (`id`)
) DEFAULT CHARSET="utf8;"

或者,也可以按如下方式更新现有 events 表:

ALTER TABLE `events` ADD COLUMN `event_pid` int(11) DEFAULT '0';
ALTER TABLE `events` ADD COLUMN `event_length` bigint(20) unsigned DEFAULT '0';
ALTER TABLE `events` ADD COLUMN `rec_type` varchar(25) DEFAULT '';

更新后端

接下来,按照循环事件指南中描述,更新处理程序。

POST 路由开始,修改 SQL 查询以包含新列。

同时,需处理循环事件的特殊情况:当删除循环系列中的某个特定事件时,客户端会触发 insert 操作,创建一条新记录来表示该删除:

app/routes.php
$group->post('', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$body = $request->getParsedBody();

$queryText = 'INSERT INTO `events` SET
`start_date`=?,
`end_date`=?,
`text`=?,
`event_pid`=?,
`event_length`=?,
`rec_type`=?';
$queryParams = [
$body['start_date'],
$body['end_date'],
$body['text'],
// 循环事件相关列
$body['event_pid'] ? $body['event_pid'] : 0,
$body['event_length'] ? $body['event_length'] : 0,
$body['rec_type']
];

// 处理循环系列中单个事件的删除
$resultAction = 'inserted'; /*!*/
if ($body['rec_type'] === "none") { /*!*/
$resultAction = 'deleted'; /*!*/
} /*!*/
/*
循环事件数据处理结束
*/

$query = $db->prepare($queryText);
$query->execute($queryParams);
$result = [
'tid' => $db->lastInsertId(),
'action' => $resultAction
];

$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});

PUT 处理程序也需要对 SQL 查询做类似更新。
另外,还需处理特殊情况:当修改循环系列时,必须删除该系列的所有已更改事件:

app/routes.php
$group->put('/{id}', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$id = $request->getAttribute('route')->getArgument('id');
parse_str(file_get_contents("php://input"), $body);
$queryText = 'UPDATE `events` SET
`start_date`=?,
`end_date`=?,
`text`=?,
`event_pid`=?,
`event_length`=?,
`rec_type`=?
WHERE `id`=?';
$queryParams = [
$body['start_date'],
$body['end_date'],
$body['text'],

$body['event_pid'] ? $body['event_pid'] : 0,
$body['event_length'] ? $body['event_length'] : 0,
$body['rec_type'],/*!*/
$id
];
if ($body['rec_type'] && $body['rec_type'] != 'none') {
// 更新循环系列时,需删除所有已修改的事件
// 参见 https://docs.dhtmlx.com/scheduler/server_integration.html#recurringevents
$subQueryText = 'DELETE FROM `recurring_events` WHERE `event_pid`=? ;';
$subQuery = $db->prepare($subQueryText);
$subQuery->execute([$id]);
}

$query = $db->prepare($queryText);
$query->execute($queryParams);

$result = [
'action' => 'updated'
];
$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});

最后,DELETE 处理程序需考虑两种特殊情况:

  • 如果要删除的事件有非空的 event_pid,说明它是循环系列的已修改实例。这时不应真正删除记录,而是将其 rec_type 更新为 'none',这样调度器会跳过该事件。
  • 若要删除整个循环系列,还需删除该系列的所有已修改实例。
app/routes.php
$group->delete('/{id}', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$id = $request->getAttribute('route')->getArgument('id');
// 循环事件相关逻辑
// https://docs.dhtmlx.com/scheduler/server_integration.html#recurringevents
$subQueryText = 'SELECT * FROM `recurring_events` WHERE id="?" LIMIT 1;';
$subQuery = $db->prepare($subQueryText);
$subQuery->execute([$id]);
$event = $subQuery->fetch(PDO::FETCH_ASSOC);

if ($event['event_pid']) {
// 删除循环系列的已修改实例
// 不直接删除,而是将 rec_type 更新为 'none',跳过该事件
$subQueryText='UPDATE `recurring_events` SET `rec_type`='none' WHERE `id`=?;';
$subQuery = $db->prepare($subQueryText);
$query->execute($queryParams);

$result = [
'action' => 'deleted'
];

$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
}

if ($event['rec_type'] && $event['rec_type'] != 'none') {/*!*/
// 删除循环系列时,同时删除所有已修改的实例
$subQueryText = 'DELETE FROM `recurring_events` WHERE `event_pid`=? ;';
$subQuery = $db->prepare($subQueryText);
$subQuery->execute([$id]);
}

/*
循环事件数据处理结束
*/

$queryText = 'DELETE FROM `events` WHERE `id`=? ;';

$query = $db->prepare($queryText);
$query->execute([$id]);

$result = [
'action' => 'deleted'
];

$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});

解析重复系列

一个重复事件会作为一条记录保存在数据库中,Scheduler 可以在客户端进行拆分。 如果需要在服务端获取每个事件的具体日期,可以使用一个辅助库来解析 dhtmlxScheduler 的重复事件(PHP 版本)。

你可以在 GitHub 上找到这个可直接使用的库

应用安全

dhtmlxScheduler 在客户端运行,并未内置安全特性以保持灵活性。 由于仅靠客户端代码无法保证可靠的安全性,后端开发者必须负责相关的安全措施。

需要注意的关键点包括:

  • SQL 注入。示例中所有操作都使用了参数化 SQL 查询,有助于防止 SQL 注入漏洞。

  • XSS 攻击。客户端不会在将用户输入发送到后端前进行过滤,服务器端数据在展示前也未经过处理。
    本示例未包含任何 XSS 过滤措施,因此如果你计划在项目中使用此示例,务必添加合适的过滤。

为防止潜在的 XSS 攻击,确保插入到 HTML 中的所有字符串都经过正确转义。 在本示例中,只需在客户端加载事件时对事件的 "text" 属性进行转义即可:

app/routes.php
$app->group('/events', function ($group) {
$group->get('', function (Request $request, Response $response, array $args) {
$db = $this->get('PDO');
$queryText = 'SELECT * FROM `events`';
$params = $request->getQueryParams();
$queryParams = [];
if (isset($params['from']) && isset($params['to'])) {
$queryText .= " WHERE `end_date`>=? AND `start_date` < ?;";
$queryParams = [$params['from'], $params['to']];
}
$query = $db->prepare($queryText);
$query->execute($queryParams);
$result = $query->fetchAll();

// 转义不安全的文本
foreach($result as $index=>$event){
$result[$index]["text"] = htmlentities($event["text"]);
}

$payload = json_encode($result);

$response->getBody()->write($payload);
return $response->withHeader('Content-Type', 'application/json');
});

错误处理

如果后端操作失败,客户端期望收到一个指明该操作 "error" 状态的响应(详见)。

你可以通过自定义默认的 Error Handler 实现这一点。 编辑 src/Application/Handlers/HttpErrorHandler.php 文件,并将以下代码段:

src/Application/Handlers/HttpErrorHandler.php
if (
!($exception instanceof HttpException)
&& ($exception instanceof Exception || $exception instanceof Throwable)
&& $this->displayErrorDetails
) {
$error->setDescription($exception->getMessage());
}

替换为:

if (
!($exception instanceof HttpException)
&& ($exception instanceof Exception || $exception instanceof Throwable)
&& $this->displayErrorDetails
) {
$encodedPayload = json_encode([
'action' => 'error', 'message' => $exception->getMessage()
]);
$response = $this->responseFactory->createResponse();
$response->getBody()->write($encodedPayload);
return $response->withHeader('Content-Type', 'application/json');
}

在客户端,可以通过 dataProcessor 的 onAfterUpdate 事件捕获这些错误:

dp.init(scheduler);
dp.attachEvent("onAfterUpdate", function(id, action, tid, response){
if(action == "error"){
// 在这里处理错误
}
});

故障排查

如果你已按照所有步骤将 Scheduler 与 PHP 集成,但页面上未显示事件,请参阅 백엔드 통합 문제 해결 文章。 该文档将帮助你定位常见问题的根本原因。

接下来

此时,你已经拥有了一个完整可用的 Scheduler。 完整代码可在 GitHub 上查看、克隆或下载到你的项目中使用。

此外,你还可以进一步阅读 涵盖 Scheduler 多种特性的指南,或关于 将 Scheduler 集成到其他后端框架的教程

Need help?
Got a question about the documentation? Reach out to our technical support team for help and guidance. For custom component solutions, visit the Services page.