非专业php选手,兼职Zentao开发。
文章目录
1. 前言
在筹备整个DevOps推进时,针对软件研发的需求管理,笔者在多方调研之后其实更为倾向JIRA,但奈何公司已经在禅道的使用上渐行渐远,胳膊拧不过大腿之下只能将注意力集中到禅道上。
年初在花费了半个月的时间将禅道的官方文档整个捋了一遍,并经过半年努力终于在部分部门内部开始被真正接受,接着就是无数基于公司自身流程特点,和操作便捷性等的需求开始不断涌现,活生生把笔者这个以Javaer逼成了个半吊子php选手。
以下从一个半吊子选手的视角,总结一些在禅道扩展开发过程中,经常会遇到的需求和相关的解决方案。
2. 起手式
从一个php外行选手,到能够进行常规禅道自定义扩展需求的开发,首先你需要的肯定是阅读相关的官方文档,这里笔者贴出认为比较有必要的:
类别 | 相关链接 | 说明 |
---|---|---|
禅道整体流程,以及所包含的相关功能的介绍 | 1. 禅道开源版 - 最简使用 2. 禅道开源版 - 基本使用 3. 禅道开源版 - 进阶使用 | 之所以你需要先了解这些内容,是因为大部分使用禅道的人对禅道的了解非常有限,有时候他们提出的所谓"扩展"其实禅道已经内置了,这个时候作为扩展开发人员的你就应该有这方面的辨识能力,别直接闷头干了还洋洋自得。 |
禅道扩展机制 | 1. 禅道开源版 - 定制开发 2. zentaoPHP框架手册 3. zentaoPHP二次开发 | 1. 其中第一个禅道开源版 - 定制开发是需要最先读的,首先是其包含的项少,内容也都是一些新手需要尽快熟悉的,例如"找到要修改的文件","如何登记权限"等等。 2. 另外两个链接里的内容也是需要尽快了解的,它会告诉如何更好地进行禅道的扩展——例如你应该将扩展代码写在哪里,而不是在禅道现有源码上直接开干。 |
关于禅道的数据库结构,官方文档上存在延迟,建议直接去禅道里在线查看:
3. 代码块snippet
3.1 后端backend
// =========================== 当前用户
$this->app->user->account
// =========================== 当前时间
// helper类定义在 ./framework/base/helper.class.php 中 义的DT_DATETIME1常量。
$now = helper::now();
// =========================== 当前时间,经常用于数据库操作时
// 在 {zentaoRootPath}module\common\lang\zh-cn.php 定义,上面的 helper::now() 正是基于它又封了一层。
DT_DATETIME1
// =========================== base64编码
# 范例: D:\xampp\zentao\module\user\control.php ajaxGetMore() 方法中
base64_decode($str1) 比如如果前端传递来的参数不可控,可以考虑前端进行base64编码,后端进行相应deocode一下,确保参数信息不丢失。
// =========================== 基于模板生成文本, 类似Java中的Velocity, Freemaker, Thymeleaf.
#
// 1. 以下赋值的变量, 在模板文件中可以直接引用
$this->view->versionDownloadUrl = $versionDownloadUrl;
// 从数据库查询信息, 作为模板文件的数据源
$xTestReport = $this->dao->select("*")
->from("zt_x_testreport")
->where("releaseID")->eq((int)$releaseID)
->fetch();
$this->view->xTestReport = $xTestReport;
// 2. 临时设置当前viewTyle为html, 确保模板文件被正确转换
$oldViewType = $this->viewType;
if($oldViewType == 'json') $this->viewType = 'html';
// 3. 实际的转换工作
$release->desc = $release->desc . "<br/> <br/>" . $this->parse('release', 'testReportOfRelease');
$this->viewType == $oldViewType;
// 4. 关键. 确保模板转换不会影响当前主体页面的绘制.
$this->clear();
// =========================== HTTP调用
// POST
$response = common::http(sprintf("http://{ip}:{port}/xxx.action?name=%s&pswd=%s","xuxiantao","XuXian123"), '', array("x"=>"123"));
// GET
$response = common::http(sprintf("http://{ip}:{port}/xxx.action?name=%s&pswd=%s","xuxiantao","XuXian123"));
3.2 前端frontend
# 按钮不可用
class="disabled"
# 分组按钮
D:\xampp\zentao\module\caselib\view\browse.html.php 56行
<div class="btn-group dropdown-hover">
<button class="btn btn-link" data-toggle="dropdown"><i class="icon icon-export muted"></i> <span class="text"><?php echo $lang->export;?></span> <span class="caret"></span></button>
<ul class="dropdown-menu pull-right" id='exportActionMenu'>
<?php
$class = common::hasPriv('task', 'export') ? '' : "class=disabled";
$misc = common::hasPriv('task', 'export') ? "class='export'" : "class=disabled";
$link = common::hasPriv('task', 'export') ? $this->createLink('task', 'export', "project=$projectID&orderBy=$orderBy&type=$browseType") : '#';
echo "<li class='disabled'>" . html::a($link, "导出用例", '', "class='export'") . "</li>";
?>
</ul>
</div>
# 多选框
// 样例参见: http://zentaoIp:zentaoPort/zentao/my-managecontacts-0-new.html
<?php
echo html::select('users[]', ["1","2"], '', "multiple class='form-control chosen' data-drop_direction='down'");
?>
4. 常用数据库操作
# D:\xampp\zentao\lib\dao\dao.class.php
# 简单的
$originCaselib = $this->dao->select("*")->from(TABLE_TESTSUITE)
->where("id")->eq($libID)
->andWhere("type")->eq("library")
->andWhere("deleted")->eq("0")
->fetch();
# 各类fetch
fetch()
fetch("id") # 返回值以 "id" 为key
fetchPairs("name", "name") # 返回值以name所查询到的对应值为key, name所查询到的对应值为value; 形成键值对
fetchAll()
# 左连接
$unfinishedStory = $this->dao->select("s.province, COUNT(1) AS 'total'")->from(TABLE_STORY)->alias("s")
->leftJoin(TABLE_PROJECTSTORY)->alias("p")->on("p.story = s.id")
->where("p.project")->eq($projectID)
->andWhere("s.deleted")->eq("0")
->groupBy('s.province')
->fetchAll();
# 直接执行SQL语句
$result = $this->dao->query(sprintf("select COUNT(1) AS recTotal from (select t1.objectID from zt_action t1
left join `zt_task` AS t2 ON t1.objectID = t2.id
LEFT JOIN `zt_project` AS t3 ON t1.project = t3.id
where t1.actor = '%s' AND t1.objectType = 'task'
group by t1.objectid) as z", $account))->fetch(PDO::FETCH_OBJ);
5. 关于调试
如此重要,以至于单独辟出一小节进行介绍
# 直接在页面上打印
var_dump($var1);
# 弹出框
die(js::alert(json_encode($var1)));
# Tips
1. 辅助以 if($this->app->user->account === 'admin') 可以实现调试和用户使用并行,得益于php的动态编译,只要别出现语法问题,禅道使用者是感知不到的。
# 日志文件
这是非常重要的一环了。
1. 开启详细日志,你需要去 D:\xampp\zentao\config\my.php 配置文件中 将debug项设置为true 。
2. 生成的日志文件存放位置:D:\xampp\zentao\tmp\log 。按天生成,其中:
2.1 sql.{yyyyMMdd}.log.php 执行过的SQL,按每次请求进行分隔,非常便于阅读和理解。
6. 一些关键文件
文件路径 | 含义 |
---|---|
lib\base\front\front.class.php | 封装常见的前端工具类代码,例如html::select()的方法就是定义在这个里面 |
lib\base\dao\dao.class.php | 数据库操作的方法 |
www\js\my.full.js | ajaxDelete() 方法 ajax处理前端代码范例 |
module\common\model.php | 包含多个全局方法,例如hasPriv() 方法就是用来检查当前访问是否满足权限? |
7. 完整示例
7.1 新增菜单项
实际例子:
# 这一步的作用应该就是将 module\common\ext\lang\zh-cn/kanq.php 中定义的
$lang->project->menu->baobiao = array('link' => 'kanq报表|project|kanqbaobiao|projectID=%s', 'alias' => 'edit,start,suspend,putoff,close');
# 将其中的 %s 进行变量替换
# 范例参见 D:\xampp\zentao\module\project\model.php 中的 setMenu($projects, $projectID, $buildID = 0, $extra = '') 方法
# 同样的操作出现在 product\\model.php 中的 setMenu($products, $productID, $branch = 0, $module = 0, $moduleType = '', $extra = '') 方法底部
common::setMenuVars($this->lang->project->menu, $key, $projectID);
# 为了确保 确保同级的菜单可以正常点击回去, 你需要在自己定义的菜单按钮响应control方法中调用如下方法
# 该方法定义在基类中
$this->commonAction($projectID);
7.2 登记菜单权限
##### D:\xampp\zentao\module\group\ext\lang\zh-cn\ 路径下 (其它语言同理)
### 新建XXX.php,名字无所谓,望文知意即可
# 1. 下面这句的目的是在 http://xxx.xx.xx.xx:zzz/zentao/group-managepriv-byGroup-1.html 权限配置页面下显示我们定义的权限
$lang->resource->project->zzzbaobiao="zzzbaobiao";
# 2. 这一句是配置上面的 "zzzbaobiao" 对应的中文名 , 同样在XXX.php文件中
$lang->project->zzzbaobiao="kanq报表";
# 3. 访问禅道页面 http://xxx.xx.xx.xx:zzz/zentao/group-managepriv-byGroup-1.html 为对应group配置 kanq报表权限
下图
7.3 datatable操作
先看看效果:
1. 基础的datatable展示信息。
2. 复选框,可以进行指定项选中。
3. 底部的统计栏,根据选中项来动态统计相关数据。
参考示例说明:
项 | 内容 |
---|---|
参考模块 | {zentaoRootPath}\module\product\view\browse.html.php |
参考模块访问URL | http://{CONTEXT}/product-browse-{productID}.html |
额外相关module | datatable |
示例代码:
// =========================== HTML页面
<!-- 这两个模块让datatable正常显示 -->
<?php include '../../common/view/header.html.php';?>
<?php include '../../common/view/datatable.fix.html.php';?>
<!-- data-ride="table" 千万别加在form上; 否则底部js失效 -->
<form id='projectUsersForm' class="main-table table-xXXXtask" method="post">
<div class="table-responsive">
<table class="table has-sort-head table-fixed" id='zprnameList'>
<?php $vars = "type=$type&orderBy=%s&recTotal=$recTotal&recPerPage=$recPerPage&pageID=$pageID"; ?>
<thead>
<tr>
<?php
$setting = $this->datatable->getSetting('project');
foreach($setting as $key => $value) { if($value->show){$this->datatable->printHead($value, "zprname", "", true);} }
?>
<!-- 当然你也可以不用上面的迭代方式, 直接用下面这种 -->
<!-- <th class='c-pri w-40px'> <?php common::printOrderLink('zprname', $orderBy, $vars, "名称");?></th> -->
</tr>
</thead>
<tbody>
<?php foreach($tasks as $task):?>
// 这里自定义的data-xx 是用作复选框选中时, 统计用的
<tr data-id='<?php echo $task->zprname?>' data-total='<?php echo $task->total?>'>
// 这里的 printXxxxCell 是需要你自定义的, 范例参考: {zentaoRootPath}/module/story/model.php ; {zentaoRootPath}/module/task/model.php 中的同名方法
<?php foreach($setting as $key => $value) $this->project->printXxxxCell($value, $task);?>
// 当然你也可以不用上面的迭代方式, 直接用下面这种
// <td class='c-total'><?php echo $task->total;?></td>
</tr>
<?php endforeach;?>
</tbody>
</table>
</div>
<div class="table-footer">
<!-- 底部的全选按钮 -->
<div class="checkbox-primary check-all"><label><?php echo $lang->selectAll?></label></div>
<!-- 统计信息栏, 内容随着用户选中datatable中每行的复选框而变化 -->
<div class="table-statistic"><?php echo $summary;?></div>
</div>
</form>
<!-- 统计栏更新 -->
<script>
// <script> 中不能有 type="text/javascript"
$(function()
{
$(function()
{ // COPY FROM: {zentaoRootPath}\module\project\view\task.html.php
// Update table summary text
$('#projectUsersForm').table(
{
statisticCreator: function(table)
{
.....
return "XXX";
}
})
});
});
</script>
// =========================== Control
public function index($projectID = '', $type = 'personstatus')
{
/* Load datatable. */
// 用作view层
$this->loadModel('datatable');
// 确保同级的菜单可以正常点击回去, 该方法定义在基类中
$this->commonAction($projectID);
$this->view->projectID = $projectID;
$this->view->tasks = $this->loadModel('xXXX')->statisticByUser($projectID);
$this->view->title = "人员状态统计";
......
$this->display();
}
// =============================== CONFIG
$config->x->datatable = new stdclass();
$config->x->datatable->defaultField = array('id');
$config->x->datatable->fieldList['id']['title'] = '名称';
$config->x->datatable->fieldList['id']['fixed'] = 'left';
$config->x->datatable->fieldList['id']['width'] = '60';
$config->x->datatable->fieldList['id']['required'] = 'yes';
// =============================== {zentaoRootPath}\module\datatable\config.php
// 这个文件中注册datatable时候需要查找的配置项; 以下配置项的意思是: 当调用 x 模块的index方法来展示datatable时, 使用到的配置项目为 $config->x 这就和上面的CONFIG 对应上了
$config->datatable->moduleAlias['x-index'] = 'x';
以上文件按下图进行放置:(别怀疑,这个例子是笔者专门建的,所以名字虽然看着随意点,但确实是有效的)
7.4 表单操作
表单操作常出现在需要新增业务字段时。
7.4.1 必填字段
// ------------------- HTML
// # 表单必填验证 : class="required"
<?php echo html::input('province', $story->province, "class='form-control' required");?>
// ------------------ CONFIG
$config->story->create->requiredFields = 'title, province';
// ------------------ LANG
$lang->story->province = "省";
// ------------------ MODLE
$requiredFields = "," . $this->config->story->create->requiredFields . ",";
$requiredFields = trim($requiredFields, ',');
$this->dao->insert(TABLE_STORY)->data($story, 'spec,verify')->autoCheck()->batchCheck($requiredFields, 'notempty')->exec();
7.4.2 字段必须为数值类型
范例参见: {zentaoRootPath}\module\task\view\recordestimate.html.php
// ------------------- HTML
// {zentaoRootPath}\module\task\view\recordestimate.html.php
<td><?php echo html::input("consumed[$i]", '', "class='form-control text-center'");?></td>
// ------------------ MODLE
// {zentaoRootPath}\module\task\model.php recordEstimate($taskID)方法
foreach($record->left as $id => $item) if(!is_numeric($item) and !empty($item)) dao::$errors[] = 'ID #' . $id . ' ' . $this->lang->task->error->leftNumber;
if(dao::isError()) return false;
7.4.3 Ajax操作
# ajax请求
// ====================== 前端
<?php
$mirrorUrl = $this->createLink('kanq', 'mirrorCaseLib', "libID=$libID&confirm=yes");
// 注意这里的 class='btn btn-link' 不要添加 export 的标记
echo html::a("javascript:ajaxDelete(\"$mirrorUrl\", \"xx\", \"镜像当前用例库?\")", '<i class="icon icon-copy"></i>', '', "title='镜像当前用例库' class='btn btn-link'");
?>
相应的 ajaxDelete() 定义在 ./www/js/my.full.js 文件中。
// ====================== 后端
// 示例为:
/* if ajax request, send result. */
if($this->server->ajax)
{
if(dao::isError())
{
$response['result'] = 'fail';
$response['message'] = dao::getError();
}
else
{
$response['result'] = 'kanq';
$response['message'] = '启动成功! 请前往<a href="http://admin:admin@172.16.3.231:8080/jenkins/job/%E6%8E%A5%E5%8F%A3%E6%B5%8B%E8%AF%95/job/000-LQ-BatchExecuteJmxByModule/" target="_blank" rel="noopener" ><strong style="font-size: 20px;">JMeter执行控制台</strong></a>查看进度';
}
$this->send($response);
}
die(js::reload('parent'));
7.5 甘特图
这里只出后台代码部分。(笔者个人是非常赞同花点钱支持国产的,奈何公司… ┑( ̄Д  ̄)┍)
<?php
include '../../control.php';
class myProject extends project
{
/**
*/
public function gantt($projectID = '', $ganttType='assignedTo')
{
// 确保同级的菜单可以正常点击回去, 该方法定义在基类中
$this->commonAction($projectID);
$projectData = $this->dao->select("t.id, t.pri, DATE_FORMAT(t.estStarted,'%d-%m-%Y') AS start_date, CONCAT('#', t.id,t.name) AS text, t.deadline, t.assignedTo AS owner_id, datediff(deadline, estStarted) AS duration, 'true' as open, CONCAT('-' ,u.id) AS parent, u.realName AS userName")->from(TABLE_TASK)->alias("t")
->leftJoin(TABLE_USER)->alias("u")->on("t.assignedTo = u.account AND u.deleted = '0'")
->where("t.project")->eq($projectID)
->andWhere("t.deleted")->eq("0")
->andWhere("t.status")->ne("closed")
->fetchAll("id");
$userNameList = array();
foreach($projectData as $taskID=>$task){
// 未正确设置"预计开始时间"的任务,直接不绘制相应的甘特图进度条; 效果见下方小节
if($task->start_date == "00-00-0000"){
$task->unscheduled = true;
}
if(array_key_exists($task->owner_id, $userNameList)){
continue;
}
$userNameList[$task->owner_id] = $task;
}
$userList = array();
foreach($userNameList as $userAccount=>$user){
$item = new stdclass();
$item->id = $user->parent;
$item->text = $user->userName;
$item->parent = "0";
$item->progress = "0";
$projectData[] = $item;
//
$item2 = new stdclass();
$item2->key = $userID;
$item2->value = $user->userName;
$userList[] = $item2;
}
$projectData = array("data" => array_values($projectData));
$this->view->projectData = json_encode($projectData);
$this->view->userList = $userList;
$this->view->projectName = "XXX";
$this->view->ganttType = $ganttType;
$this->view->projectID = $projectID;
$this->display();
}
}
7.5.1 优化 - 未填写预计开始时间的任务不绘制进度
言语太苍白,咱们直接上图说明。
禅道中如果任务被创建时候没有输入"预计开始时间"字段,则在禅道中其默认值为"0000-00-00"。这种情况下的甘特图会出现非常长的横向滚动条,如下图:
遇到上面这种情况,其实最正确的方式应该是将任务打回,要求相应人员正确填写;但奈何业务压力大,相关管理人员能力尚不足等原因,我们只能退而求其次地先消除这些超长进度条。
最终在官网文档下找到了这篇API说明 - Unscheduled Tasks , 只需要将相应地数据源中添加一个名为unscheduled=true
的键值对即可。最终效果如下:
7.6 搜索栏增加额外查询字段
依然是先看效果:
可以看出,zentao在扩展性方面下了不少功夫,以上需求只需要修改一个配置文件即可。
// D:\xampp\zentao\module\product\config.php
$config->product->search['fields']['province'] = "所在省";
// 这里如果进一步优化,可以设置为选择框,而非输入框
$config->product->search['params']['province'] = array('operator' => 'include', 'control' => 'input', 'values' => '');
7.7 datatable默认列名的自定义
禅道里的datatable列表中的默认展示列有两种定义形式,一种是在 xxx.html.php
中直接使用<table><th>xx</th></table>
进行定义,这种方式对有开发经验的人员进行自定义扩展来说很直观。
除此之外的第二种方式则如下图 —— 直接使用界面化配置的方式实现 (这种方式的好处是无需研发资源的再次投入,当事用户即可自己实现自定义需求)。
本小节的关注点就是对于上述第二种形式,如何实现默认展示列的调整。
相关的修改涉及两个位置:
- 配置文件
config.php
。这里定义了默认需要展示的列名集合。用户初次使用禅道时所展示的列名正是来自这里。 - 数据库表
zt_config
。 这里定义了每个用户自定义的需要需要列名集合。
# 1. 配置文件 `config.php` (定义datatable默认展示列)
# 样例:{zentaoRootPath}\module\bug\config.php
# 注意:调整完这里之后,还需要删除数据库表zt_config中的对应记录,让本配置生效; 具体参见接下来马上出现的步骤2
$config->bug->datatable->defaultField = array('id', 'severity', 'pri', 'title', 'status', 'openedDate', 'deadline', , 'type', 'assignedTo', 'resolution', 'actions');
# 2. 数据库表 `zt_config`
# 上面截图里用户自定义的列配置最终会被存放在这里. 而且如果在这里没有找到,则会将上面config.php里的配置往这里写一份. (这也是为啥本次二开会同时涉及配置文件和数据库的原因)
delete from zt_config where section = 'bugBrowse' and `key` = 'tablecols'
select * from zt_config where section = 'bugBrowse' and `key` = 'tablecols' and owner = 'admin'
8. 参考
- PHP 教程 - W3school
- 禅道 - 最简使用
- Zentao - 二次开发文档
- zentaoPHP框架提供的DAO功能
- 依靠工具,让员工天天简简单单的管理自己的工作。 - 没错,笔者就是那"谁喝多了搞禅道二次开发的"。
- dhtmlx - Gantt API