博主头像
QA角落

在这个角落,我记录着我的测试心得和见解,以及我在软件测试领域中的成长与探索。这是一个低调而充满思考的地方,我以文字和代码,静静地描绘着测试的轨迹。欢迎来到安静测试角落,与我一同探索测试的无限可能性,留下你的足迹和思考,让我们一起在这个角落中创造属于测试的静谧与美好。

禅道21版本二次开发-字段扩展篇

一、前言

准备把公司的禅道升级到最新的21版本,但是原来对禅道进行过二次开发,而且以前二次开发的时候是随便找了一篇博客照着抄,直接改的源码,升级后二次开发字段就全都没有了,只能研究官方文档重新进行二次开发,这回开发的是插件,不会再次因为升级的原因导致之前写的内容丢失了,然后开发的过程中就遇到了第二个问题,18版本升级到20版本以后UI目录有了一些变化,导致开发的时候代码一直不生效,很是恼火,所以做一个记录,避免自己再次踩坑,以及也许会帮到和我有一样升级到20版本以上想法的人少走一些弯路。

二、开发前了解内容

1、尽量别动源码

重要提醒:所有二次开发尽量在 extension 目录下进行,切勿直接修改禅道源码! 如果必须修改源码,请撰写详细的 README 文档记录修改内容。因为禅道升级会覆盖源码,导致修改内容丢失。

插件开发目录位于禅道安装路径下的 zentao/extension/custom/

  • Linux 一键安装包:通常在 /opt/zbox/app/zentao/extension/custom/
  • Windows 一键安装包:位于解压目录,例如 D:\ZenTao\app\zentao\extension\custom\

要扩展某个模块,在 custom 目录下创建对应模块目录。例如,扩展 BUG 模块,就在 custom 下创建 bug/ext/ 目录,结构如下:

zentao/extension/custom/bug/ext/

2、插件目录结构

一个标准的插件扩展,目录结构长这样。

/zentao/extension/custom/bug/ext/
├── config/      # 模块本身的配置项
├── control/     # 控制层,页面访问的入口代码和逻辑
├── js/          # 前端JavaScript代码
├── lang/        # 模块的语言文件
├── model/       # 模型层,主要是对数据库的操作代码
├── ui/          # 新的视图层,20版本之后新的视图文件
├── view/        # 旧的视图层,18版本及之前的视图文件存放在这里
├── css/         # 前端样式文件
├── tao/         # 模型子层,model层的一些公用或基础数据库操作放在这里
└── zen/         # 控制子层,control层的内部代码会放在zen层

3、禅道开发手册

对应源码目录位置zentao/model,不是为了让你修改源码,是可以查找参考源码写法方便覆写model目录下目录对应的是模块名称。

三、实战开始:给 Bug 加上“开发者”和“类别”

本人比较懒,只限于了解PHP,所有修改全部是复制源码在上面进行的修改,采用的覆盖的方式,不会使用官方文档钩子的方式。

1、数据库增加字段

在 zt_bug 表中添加 developer 和 category 两个列,类型分别为 VARCHAR(100) 和 VARCHAR(50),默认值为空字符串 '',并分别添加注释‘开发者’和‘类别’。

ALTER TABLE `zt_bug`
ADD COLUMN `developer` VARCHAR(100) DEFAULT '' COMMENT '开发者',
ADD COLUMN `category` VARCHAR(50) DEFAULT '' COMMENT '类别';

2、覆写model目录下方法

model.php下方法可以直接处理新增字段,无需覆写方法,如需覆写在ext目录下创建model目录并创建对应方法名文件,如创建bug,对应方法名是createmodel目录下覆写创建create.php文件,内容复制create方法不要有class类名,方法名前public去掉。update方法同理。

3、覆写lang语言文件

禅道支持多语言,我只使用中文,所以在lang目录下创建中文目录zh-cn目录后创建任意名称后缀为.php结尾文件就行

  • 文件路径: ext/lang/zh-cn/bug.php
<?php
/**
 * 扩展字段.
 */
// 添加新字段的语言定义
$lang->bug->category = 'Bug种类';
$lang->bug->developer  = '开发者';

// 定义bug种类选项
$lang->bug->categoryList = array();
$lang->bug->categoryList['']           = '';
$lang->bug->categoryList['minor']    = '次要问题';
$lang->bug->categoryList['normal']   = '普通问题';
$lang->bug->categoryList['serious']  = '严重问题';
$lang->bug->categoryList['critical'] = '关键问题';
$lang->bug->categoryList['urgent']   = '紧急问题';

4、覆写config配置文件

配置文件,config目录下可以创建一个或者多个对应覆写文件,都可以正常加载覆写,我是创建了多个文件和源码目录一一对应覆写便于理解。

  • 文件路径: ext/config/config.php 对应源码config.php文件中内容
<?php
/**
 * 扩展字段.
 */
// 所有字段,页面显示会增加显示的字段
$config->bug->list->allFields = 'id, module, execution, story, task,
    title, keywords, severity, pri, type, os, browser, category, developer, hardware,
    found, steps, status, deadline, activatedCount, confirmed, mailto,
    openedBy, openedDate, openedBuild,
    assignedTo, assignedDate,
    resolvedBy, resolution, resolvedBuild, resolvedDate,
    closedBy, closedDate,
    duplicateBug, relatedBug,
    case,
    lastEditedBy,
    lastEditedDate';

// 导出字段,导出文件时会导出新增加的字段
$config->bug->exportFields = 'id, product, branch, module, project, execution, story, task,
    title, keywords, severity, pri, type, os, browser, category, developer,
    steps, status, deadline, activatedCount, confirmed, mailto,
    openedBy, openedDate, openedBuild,
    assignedTo, assignedDate,
    resolvedBy, resolution, resolvedBuild, resolvedDate,
    closedBy, closedDate,
    duplicateBug, relatedBug,
    case,
    lastEditedBy,
    lastEditedDate, files ,feedbackBy, notifyEmail';
  • 文件路径: ext/config/form.php 对应源码form.php文件中内容
<?php
/**
 * 扩展字段,创建和更新时会处理的字段
 */
$config->bug->form->create['category'] = array('required' => false, 'type' => 'string', 'default' => '');
$config->bug->form->create['developer'] = array('required' => false, 'type' => 'string', 'default' => '');


$config->bug->form->edit['category'] = array('required' => false, 'type' => 'string', 'default' => '');
$config->bug->form->edit['developer'] = array('required' => false, 'type' => 'string', 'default' => '');
  • 文件路径: ext/config/search.php 对应源码search.php文件中内容
<?php
/**
 * 扩展字段.
 */
// 搜索字段
$config->bug->search['fields']['developer']        = $lang->bug->developer;
$config->bug->search['params']['developer']    = array('operator' => '=',       'control' => 'select', 'values' => 'users');
$config->bug->search['fields']['category']        = $lang->bug->category;
$config->bug->search['params']['category']       = array('operator' => 'include', 'control' => 'select', 'values' => $lang->bug->categoryList);
  • 文件路径: ext/config/table.php 对应源码table.php文件中内容
<?php
/**
 * 扩展字段.
 */
// 更新默认显示字段
$config->bug->dtable->defaultField = array('id', 'title', 'developer', 'severity', 'pri', 'status', 'openedBy', 'openedDate', 'confirmed', 'assignedTo', 'resolution', 'actions');

// 添加developer字段到列表
$config->bug->dtable->fieldList['developer']['title']    = $lang->bug->developer;
$config->bug->dtable->fieldList['developer']['type']     = 'user';
$config->bug->dtable->fieldList['developer']['group']    = 3;
$config->bug->dtable->fieldList['developer']['sortType'] = true;
$config->bug->dtable->fieldList['developer']['show']     = true;

5、覆写control目录下方法

model的覆写方法一样,原control.php下方法可以直接处理新增字段,无需覆写方法,如需覆写在ext目录下创建control目录并创建对应方法名文件,如创建bug,对应方法名是createcontrol目录下覆写创建create.php文件,内容复制create方法不要有class类名,方法名前public去掉。update方法同理。

6、js处理加载全部人员

开发者默认显示的是当前项目下的人员,如人员不在当前项目下时点击加载全部按钮显示禅道下所有人员,create和exit方法一模一样,我比较懒,没有写公共方法,直接复制粘贴搞了两遍。注意目录创建,对create页面扩展要先创建create目录,再创建create.js文件;对edit页面扩展要先创建edit目录,再创建edit.js文件。还有就是加载全部没有使用新版UI的方法采用勾选的方式,勾选的方式比较简单,选中就显示所有人员,不选中就不显示可以撤回,我的方法是18版本的方法加载后不能撤回,原因是我没研究明白新版的方法,新版请求返回的是整个html加载的实在是不想耗精力在这里了。

  • 文件路径: ext/js/create/create.js
function loadAllUsersFordeveloper()
{
    const isClosedBug = bug.status == 'closed';
    const params = isClosedBug ? 'params=devfirst' : 'params=devfirst,noclosed';
    const link = $.createLink('bug', 'ajaxLoadAllUsers', params);

    $.getJSON(link, function(data)
    {
        $('[name="developer"]').zui('picker').render({items: data}); // 硬编码更新 developer 的 picker
    });
}
  • 文件路径: ext/js/create/edit.js
function loadAllUsersFordeveloper()
{
    const isClosedBug = bug.status == 'closed';
    const params = isClosedBug ? 'params=devfirst' : 'params=devfirst,noclosed';
    const link = $.createLink('bug', 'ajaxLoadAllUsers', params);

    $.getJSON(link, function(data)
    {
        $('[name="developer"]').zui('picker').render({items: data}); // 硬编码更新 developer 的 picker
    });
}

7、视图层覆写

还是采用的覆盖的方法,直接复制所有源码后修改的,展示的时候就不显示全部了,内容太多了。

  • 文件路径: ext/ui/common.field.php 覆写公共方法,复制源码后在自己想展示的位置粘贴以下代码,控制页面显示
```源码上面部分```

// 添加bug种类字段
$fields->field('category')
    ->label($lang->bug->category)
    ->control('picker')
    ->items($lang->bug->categoryList)
    ->value(data('bug.category'));

// 添加开发者字段
$fields->field('developer')
    ->label($lang->bug->developer)
    ->control('inputGroup')
    ->itemBegin('developer')->control('picker')->items(data('productMembers'))->value(data('bug.developer'))->itemEnd();

```源码下面部分```
  • 文件路径: ext/ui/create.field.php 覆写创建页面,复制源码后在自己想展示的位置粘贴以下代码,主要针对的是加载全部功能,否则可以没有这一部分。
```源码上面部分```

//  添加开发者字段
$fields->field('developer')
    ->items(false)
    ->itemBegin('developer')
        ->control('picker')
        ->items(data('productMembers'))
        ->value(data('bug.developer'))
    ->itemEnd()
    ->itemBegin('loadAllDevUsersBtn')
        ->control('btn', [
            'id'        => 'allDevUsers',
            'text'      => $lang->bug->loadAll,
            'href'      => 'javascript:;',
            'className' => 'input-group-addon',
        ])
    ->itemEnd();

```源码下面部分```
  • 文件路径: ext/ui/create.html.php 覆写创建页面,也是处理加载全部按钮
<?php
namespace zin;

include($this->app->getModuleRoot() . 'ai/ui/inputinject.html.php');

$fields = useFields('bug.create');
if(!empty($executionType) && $executionType == 'kanban') $fields->merge('bug.kanban');

$fields->autoLoad('branch',    'module,execution,project,story,task,assignedTo')
       ->autoLoad('module',    'assignedTo,story')
       ->autoLoad('project',   'project,execution,story,task,assignedTo,injection,identify,openedBuild')
       ->autoLoad('execution', 'execution,story,task,assignedTo,openedBuild')
       ->autoLoad('allBuilds', 'openedBuild')
       ->autoLoad('allUsers',  'assignedTo')
       ->autoLoad('region',    'lane')
       ->autoLoad('allDevUsers',   'developer'); //加载全部

if(!$product->shadow) $fields->fullModeOrders('module,project,execution,plan', 'pri,title');
$fields->sort('execution,plan');

jsVar('bug',                   $bug);
jsVar('moduleID',              $bug->moduleID);
jsVar('methodName',            $app->methodName);
jsVar('projectID',             isset($projectID)   ? $projectID   : 0);
jsVar('executionID',           isset($executionID) ? $executionID : 0);
jsVar('tab',                   $this->app->tab);
jsVar('createRelease',         $lang->release->create);
jsVar('refresh',               $lang->refreshIcon);
jsVar('projectExecutionPairs', $projectExecutionPairs);

formGridPanel
(
    on::change('[name="product"]', 'reloadByProduct'),
    on::change('[name="branch"], [name="project"], [name="execution"]', 'loadBuilds'),
    on::click('#allDevUsers', 'loadAllUsersFordeveloper'), // 绑定 developer 的“加载全部”按钮
    set::title($lang->bug->create),
    set::fields($fields),
    set::loadUrl($loadUrl)
);
  • 文件路径: ext/ui/edit.html.php 覆写编辑页面,复制源码后在自己想展示的位置粘贴以下代码
```源码上面部分```

    on::click('#allUsers',       'loadAllUsers'),  //搜索此代码下面增加开发者加载全部的处理
    on::click('#allUsersdeveloper', 'loadAllUsersFordeveloper'),  //开发者加载全部

```省略展示部分源码```

item
            (
                set::name($lang->bug->category),  // 新增bug种类字段
                set::required(strpos(",{$config->bug->edit->requiredFields},", ',type,') !== false),
                formGroup
                (
                    picker
                    (
                        set::items($lang->bug->categoryList),
                        set::name('category'),
                        set::value($bug->category)
                    )
                )
            ),
            item
            (
                set::name($lang->bug->developer),  // 新增开发者字段
                set::required(strpos(",{$config->bug->edit->requiredFields},", ',type,') !== false),
                formGroup
                (
                    inputGroup
                    (
                        picker
                        (
                            set::disabled($bug->status == 'closed' ? true : false),
                            set::name('developer'),
                            set::items($assignedToList),
                            set::value($bug->developer)
                        ),
                        span
                        (
                            set('class', 'input-group-addon'),
                            a
                            (
                                set('id', 'allUsersdeveloper'),
                                set('href', 'javascript:;'),
                                $lang->bug->loadAll
                            )
                        )
                    )
                )
            ),

```源码下面部分```
  • 文件路径: zentao/lib/zin/wg/bugbasicinfo/v1.php 修改查看页面,这里就是前面说的尽量不要修改源码原因,当前版本查看页面要是显示新增加字段就要修改源码(论坛问的官方),以后也许官方会实现不修改源码可以显示增加字段的方法,看个人情况自己决定是否修改增加显示,如果要修改,目录位置在zentao/lib/zin/wg/bugbasicinfo/,编辑v1.php不建议修改,如修改做好readme备注,我使用的方法是复制了bugbasicinfo修改目录名称和方法名称,并复制view.html修改调用方法名并做好了readme备注。下面为v1.php中增加的代码,自己寻找合适的显示位置。
   // 视图页面增加自定义字段。
   $items[$lang->bug->developer]     = zget($users, $bug->developer);
   $items[$lang->bug->category] = zget($lang->bug->categoryList, $bug->category, $bug->category);

8、覆写zen目录下方法

model的覆写方法一样,在ext目录下创建zen目录并创建对应方法名文件extractObjectFromExtras.php,内容复制extractObjectFromExtras方法不要有class类名,方法名前public去掉。这个方法是处理复制bug时带着新增字段的数据,否则复制的时候数据不会带过来。

<?php
/**
 * 解析extras,如果bug来源于某个对象 (bug, case, testtask, todo) ,使用对象的一些属性对bug赋值。
 * Extract extras, if bug come from an object(bug, case, testtask, todo), get some value from object.
 *
 * @param  object    $bug
 * @param  array     $output
 * @access protected
 * @return object
 */
function extractObjectFromExtras($bug, $output)
{
    extract($output);
    if (isset($resultID)) {
        $resultID = (int) $resultID;
    }

    if (isset($caseID)) {
        $caseID = (int) $caseID;
    }

    /* 获取用例的标题、步骤、所属需求、所属模块、版本、所属执行。 */
    /* Get title, steps, storyID, moduleID, version, executionID from case. */
    if (isset($runID) && $runID && isset($resultID) && $resultID) {
        $fields = $this->bug->getBugInfoFromResult($resultID, isset($caseID) ? $caseID : 0, isset($stepIdList) ? $stepIdList : '');
    }
// If set runID and resultID, get the result info by resultID as template.
    if (isset($runID) && ! $runID && isset($caseID) && $caseID) {
        $fields = $this->bug->getBugInfoFromResult($resultID, $caseID, isset($stepIdList) ? $stepIdList : '');
    }
// If not set runID but set caseID, get the result info by resultID and case info.
    if (isset($fields)) {
        $bug = $this->updateBug($bug, $fields);
    }

    /* 获得bug的所属项目、所属模块、所属执行、关联产品、关联任务、关联需求、关联版本、关联用例、标题、步骤、严重程度、类型、指派给、截止日期、操作系统、浏览器、抄送给、关键词、颜色、所属测试单、反馈人、通知邮箱、优先级。 */
    /* Get projectID, moduleID, executionID, productID, taskID, storyID, buildID, caseID, title, steps, severity, type, assignedTo, deadline, os, browser, mailto, keywords, color, testtask, feedbackBy, notifyEmail, pri from case. */
    if (isset($bugID) && $bugID) {
        $bugInfo       = $this->bug->getById((int) $bugID);
        $isSameProduct = $this->session->product == $bugInfo->product;

        $fields = ['projectID' => $bugInfo->project, 'moduleID' => $bugInfo->module, 'executionID' => $bugInfo->execution, 'taskID' => $bugInfo->task, 'storyID'   => $isSameProduct ? $bugInfo->story : 0, 'buildID' => $bugInfo->openedBuild,
            'caseID'                    => $bugInfo->case, 'title'       => $bugInfo->title, 'steps'        => $bugInfo->steps, 'severity'   => $bugInfo->severity, 'type'  => $bugInfo->type, 'assignedTo'                    => $bugInfo->assignedTo, 'deadline' => (helper::isZeroDate($bugInfo->deadline) ? '' : $bugInfo->deadline),
            'os'                        => $bugInfo->os, 'browser'       => $bugInfo->browser, 'mailto'     => $bugInfo->mailto, 'keywords'  => $bugInfo->keywords, 'color' => $bugInfo->color, 'testtask'                     => $bugInfo->testtask, 'feedbackBy' => $bugInfo->feedbackBy, 'notifyEmail' => $bugInfo->notifyEmail,
            'pri'                       => ($bugInfo->pri == 0 ? 3 : $bugInfo->pri),
            'plan'                      => $bugInfo->plan,
            'developer'                    => $bugInfo->developer,
            'category'                   => $bugInfo->category,
        ];

        $bug = $this->updateBug($bug, $fields);

        $bug->files = $bugInfo->files;
        foreach ($bug->files as $file) {
            $file->name = $file->title;
            $file->url  = $this->createLink('file', 'download', "fileID={$file->id}");
        }

        if ($this->config->edition != 'open') {
            $fields       = [];
            $extendFields = $this->loadModel('flow')->getExtendFields('bug', 'create');
            foreach ($extendFields as $field) {
                $fields[$field->field] = $bugInfo->{$field->field};
            }

            $bug = $this->updateBug($bug, $fields);
        }
    }

    /* 获取测试单的版本。 */
    /* Get buildID from testtask. */
    if (isset($testtask) and $testtask) {
        $testtask = $this->loadModel('testtask')->getByID((int) $testtask);
        $bug      = $this->updateBug($bug, ['buildID' => $testtask->build]);
    }

    /* 获得代办的标题、步骤和优先级。 */
    /* Get title, steps, pri from todo. */
    if (isset($todoID) and $todoID) {
        $todo = $this->loadModel('todo')->getById((int) $todoID);
        $bug  = $this->updateBug($bug, ['title' => $todo->name, 'steps' => $todo->desc, 'pri' => $todo->pri]);
    }

    return $bug;
}

暂时没发现还有漏掉的地方,有了后期补充,注意视图部分我是直接用的我的当前版本21.7.2改的,不同小版本之间可能会存在差异,大的方向应该是不影响。

四、打包安装包

1、压缩包目录结构介绍

customFields                                           # 安装包目录
├── doc                                                # 文档目录
│   ├── README.MD                                      # 插件介绍文档
│   └── zh-cn.yaml                                     # 版本号兼容性校验登内容
└── extension                                          # 插件目录
    └── custom
        └── bug                                        # 要覆写模块目录
            └── ext
                ├── config                             
                │   ├── config.php
                │   ├── form.php
                │   ├── search.php
                │   └── table.php
                ├── control
                ├── js
                │   ├── create
                │   │   └── create.ui.js
                │   └── edit
                │       └── edit.ui.js
                ├── lang
                │   └── zh-cn
                │       └── bug.php
                ├── model
                ├── ui
                │   ├── common.field.php
                │   ├── create.field.php
                │   ├── create.html.php
                │   ├── edit.html.php
                │   └── view.html.php
                └── zen
                    └── extractObjectFromExtras.php

以上目录结构文档打包成zip就可以在禅道后台-插件模块安装了,或者不打包压缩直接将代码复制到对应插件目录。

2、zh-cn.yaml文件结构简介

---
name: BUG 自定义字段   
code: customFields
type: extension
copyright: >
  Copyright 2025
site: https://qa.5866666.xyz   
author: 作者名称
abstract: 为禅道 BUG 模块添加自定义字段   # 插件简介 
desc: |   # 插件详细描述
   本插件为禅道 BUG 模块添加“开发者”和“类别”字段,支持创建、编辑、搜索和导出功能。
install: |  # 安装方法
  1. 解压至 zentao/extension/custom/ 目录
  2. 在数据库执行 SQL 添加字段
  3. 在禅道后台启用插件
license: 本插件基于 MIT 许可证发布,允许自用和修改,禁止商业分发。    
releases:
  1.0.1:    # 插件版本号
    zentao:
      compatible: >    # 插件兼容的禅道版本,安装时禅道会校验
        21.7.2,21.7.1,21.6,21.5,21.4    
      incompatible:
    charge: free    # 是否收费
    date: 2025-07-30   # 开发时间
    conflicts:   none  # 冲突
    depends:    none  # 依赖
    changelog: 初始版本,支持开发者与类别字段 # 版本日志。

五、卸载

如需卸载插件,可在禅道后台插件管理页面卸载,或直接删除 extension/custom/bug/ext/ 目录。别忘了执行以下 SQL 清理数据库:

ALTER TABLE `zt_bug` DROP `developer`;
ALTER TABLE `zt_bug` DROP `category`;

踩坑总结

  1. 源码修改风险:直接修改 zentao/lib/zin/wg/bugbasicinfo/v1.php 可在查看页面显示新增字段,但升级会覆盖源码,建议复制并重命名目录和方法,做好 README 记录。
  2. UI 目录变化:20 版本后,视图层移至 ui/ 目录,18 版本使用 view/,开发时需注意版本差异。
  3. 加载全部用户:本例使用 18 版本的简单实现,加载后不可撤销。新版 UI 使用勾选方式更灵活,但需额外研究。
  4. 版本兼容性:不同小版本可能存在细微差异,建议测试后再部署。
发表新评论