《应用Yii1.1和PHP5进行敏捷Web开发》

第六章:迭代3:添加任务(Task)

在上一个迭代中,我们交付了与项目(Project)实体相关的基本功能。项目(Project)是TrackStar应用的基础。但是,项目(Project)本身用处并不是很大。我们希望使用这个应用来管理问题(Issue),而项目(Project)只是问题(Issue)的基本容器。由于管理项目(Project)中的问题(Issue)是开发这款应用的主要目的,所以我们将这个迭代主要用于添加一些基本的问题(Issue)管理功能。

迭代计划

我们已经有了创建和列出项目(Project)的功能,但是这些项目(Project)还不能包含任何东西。在这个迭代结束后,我们希望应用程序能提供所有关于问题(Issue)或者说任务(Task)的CRUD(增删改查)操作(我们会交替使用术语问题(Issue)和任务,但是在我们的数据模型中,一个任务实际上只是问题(Issue)的一种)。我们还需要将所有对问题(Issue)进行的CRUD操作,限制在一个特定的项目(Project)上下文中进行。也就是说,问题(Issue)是从属于项目(Project)的。在进行任何对问题(Issue)的CRUD操作之前,用户必须首先选定一个已经存在的项目(Project)并在该项目(Project)之下进行工作。

为了实现上述目标,我们需要识别出所有要在此迭代中完成的细化工作。下面的列表是对这些工作的概括:

  • 设计数据库结构,创建对象来支持问题(Issue)
  • 创建Yii模型类,使应用能够简单地与我们创建的数据库表交互
  • 创建控制器类,来容纳我们需要的功能,包括:
    • 创建新问题(Issue)
    • 从数据库中取回项目(Project)中问题(Issue)的列表
    • 更新、编辑问题(Issue)
    • 删除问题(Issue)
  • 创建试图来为这些动作(上述的)生成用户界面

这些信息已经足够我们开始干活了。运行完测试后,我们开始做一些必要的数据库修改。

运行测试套件

在深入到开发工作中之前,运行已经存在的测试套件,总是一个非常好的主意。我们的测试套件在上一个迭代的时候增长了一点。现在我们已经有了对项目(Project)进行CRUD操作和数据库链接的测试。把它们一起再运行一次。打开测试文件夹,/protected/tests/unit,然后运行所有单元测试:

%phpunit unit/
PHPUnit 3.3.17 by Sebastian Bergmann. 
..... 
Time: 0 seconds OK (5 tests, 11 assertions)

都通过了。让我们开始进行修改。

设计数据库结构

回到第3章TrackStar应用,我们提出了一些关于问题(Issue)的初始想法。我们假设它有类型、所有人、请求人(requester)、状态和描述等自然属性。还提到了当我们创建了表tbl_project的时候,会给每个表添加基本的审计历史信息,用于跟踪更新了表的用户、日期和时间。在这个过程中,需求并没有发生改变,所以我们可以继续进行初始的计划。但是,类型、所有人、请求人和状态这些属性本身就是它们自己的实体。为了保持模型的弹性和扩展性,我们会对这些属性分别建模。所有人和请求人都是系统的用户,将会指向tbl_user表中的一行。在表tbl_project中,我们已经引入了用户的概念,我们添加了create_user_id和update_user_id两列,来跟踪创建了这个项目(Project)和对这个项目(Project)细节最后一次更新负责的用户的标识符。尽管还没有正式引入用户表,这些域已经被设定成数据库中另一个存储用户的表的外键。在表tbl_issue中,owner_id和requestor_id也是指向表tbl_user的外键。

我们可以用同样的方式来为类型和状态属性建模。不过,在需求要求模型具备这些额外的复杂度之前,我们让事情简单一些。表tbl_issue中的type和status列仍将是整数值类型,映射到命名的类型和状态。我们把这些属性建模成问题(Issue)实体的AR模型类的常量值,而不是使用分开的表来完善我们的模型。如果所有的这一切看起来有一点迷惑,请不要担心,在接下来的章节中,这些都会变清楚的。

定义一些关系

因为要引入表tbl_user,我们回过头来定义一下用户和项目(Project)之间的关系。第三章引入TrackStar应用的时候,我们设定用户(项目成员)可以和一个或者多个项目关联。还提到项目可以有许多(0个或多个)用户。因为项目可以有多个用户,而且用户可以被关联到多个项目,我们称之为项目和用户之间的多对多关系。在关系型数据库中,对一个多对多关系建模的最简单方法是,使用关联或者赋值表(assignment table)。所以,我们也需要将这个表添加到我们的模型中。

下图勾勒出一个基本的实体关系,这个关系就是我们需要在用户,项目(Project),问题(Issue)之间建立的模型关系。项目可以有0个或者多个用户参与,一个用户至少要被关联到一个项目,但是也可以被关联到多个项目。问题(Issue)属于且仅属于一个项目,同时项目可以包含0个或者多个问题(Issue)。最后,一个问题(Issue)只能被分配给一个用户(或者由一个用户提出)。

建立数据库和关系

那么,我们需要创建三张新表:tbl_issue,tbl_user和我们的关联表tbl_project_user_assignment。为了让您方便一点,我们提供了基本的数据定义语言(DDL)语句用于创建表和它们之间的关系。由于基本的用户管理不是这个迭代的一部分,我们还提供了一点种子数据填充到用户表中,以使我们可以立即使用它们。请像前几个迭代你做的那样,创建下列的表和关系。下列的语法假设你使用的是MySQL数据库:

DROP TABLE IF EXISTS `tbl_project`;
CREATE TABLE `tbl_project`
(
    `id` INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `name` VARCHAR(128),
    `description` TEXT,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER
) ENGINE = InnoDB;
 
 
CREATE TABLE IF NOT EXISTS `tbl_issue`
(
    `id` INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `name` VARCHAR(256) NOT NULL,
    `description` VARCHAR(2000),
    `project_id` INTEGER,
    `type_id` INTEGER,
    `status_id` INTEGER,
    `owner_id` INTEGER,
    `requester_id` INTEGER,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER,
     INDEX (`project_id`)
) ENGINE = InnoDB;
 
 
CREATE TABLE IF NOT EXISTS `tbl_user`
(
    `id` INTEGER NOT NULL PRIMARY KEY AUTO_INCREMENT,
    `email` VARCHAR(256) NOT NULL,
    `username` VARCHAR(256),
    `password` VARCHAR(256),
    `last_login_time` DATETIME,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER
) ENGINE = InnoDB;
 
 
CREATE TABLE IF NOT EXISTS `tbl_project_user_assignment`
(
    `project_id` INT(11) NOT NULL,
    `user_id` INT(11) NOT NULL,
    `create_time` DATETIME,
    `create_user_id` INTEGER,
    `update_time` DATETIME,
    `update_user_id` INTEGER,
    PRIMARY KEY (`project_id`,`user_id`)
) ENGINE = InnoDB;
 
 
-- The Relationships
ALTER TABLE `tbl_issue` ADD CONSTRAINT `FK_issue_project`
FOREIGN KEY (`project_id`) REFERENCES `tbl_project` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_issue` ADD CONSTRAINT `FK_issue_owner`
FOREIGN KEY (`owner_id`) REFERENCES `tbl_user` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_issue` ADD CONSTRAINT `FK_issue_requester`
FOREIGN KEY (`requester_id`) REFERENCES `tbl_user` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_project_user_assignment`
ADD CONSTRAINT `FK_project_user` FOREIGN KEY (`project_id`)
REFERENCES `tbl_project` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
 
ALTER TABLE `tbl_project_user_assignment`
ADD CONSTRAINT `FK_user_project` FOREIGN KEY (`user_id`)
REFERENCES `tbl_user` (`id`)
ON DELETE CASCADE ON UPDATE RESTRICT;
 
-- Insert some seed data so we can just begin using the database 
 
INSERT INTO `tbl_user`
(`email`, `username`, `password`)
VALUES
('test1@notanaddress.com','Test_User_One', MD5('test1')),
('test2@notanaddress.com','Test_User_Two', MD5('test2'));

创建Active Record模型类

建立好了这些表后,为了能与它们简单地交互,我们需要创建Yii的AR模型类。在第5章 迭代2:项目CRUD中,使用Gii代码生成工具创建过Project.php模型类时,我们就做过这件事。在这里,我们会把步骤再提醒你一次,但是省略了截屏。用Gii工具的更多细节请参考第5章。

创建问题(Issue)模型类

访问http://localhost/trackstar/index.php?r=gii打开Gii工具页面,选择模型生成器链接。表前缀保留tbl_。表名(Table Name)填写tbl_issue,模型类(Model Class)格会自动填上Issue。

填写后,点击预览按钮,会得到一个链接,那个链接会打开一个弹出框,里面将展示所有将要生成的代码。然后点击生成按钮来真正创建Issue.php模型类文件到/protected/model文件夹。生成代码的完整列表如下:

/**
 * This is the model class for table "tbl_issue".
 *
 * The followings are the available columns in table 'tbl_issue':
 * @property integer $id
 * @property string $name
 * @property string $description
 * @property integer $project_id
 * @property integer $type_id
 * @property integer $status_id
 * @property integer $owner_id
 * @property integer $requester_id
 * @property string $create_time
 * @property integer $create_user_id
 * @property string $update_time
 * @property integer $update_user_id
 *
 * The followings are the available model relations:
 * @property User $requester
 * @property User $owner
 * @property Project $project
 */
class Issue extends CActiveRecord {
 
    /**
     * Returns the static model of the specified AR class.
     * @return Issue the static model class
     */
    public static function model($className=__CLASS__) {
        return parent::model($className);
    }
 
    /**
     * @return string the associated database table name
     */
    public function tableName() {
        return 'tbl_issue';
    }
 
    /**
     * @return array validation rules for model attributes.
     */
    public function rules() {
        // NOTE: you should only define rules for those attributes that
        // will receive user inputs.
        return array(
            array('name', 'required'),
            array('project_id, type_id, status_id, owner_id, requester_id, create_user_id, 
                       update_user_id', 'numerical', 'integerOnly' => true),
            array('name', 'length', 'max' => 256),
            array('description', 'length', 'max' => 2000),
            array('create_time, update_time', 'safe'),
            // The following rule is used by search().
            // Please remove those attributes that should not be searched.
            array('id, name, description, project_id, type_id, status_id, owner_id, 
                        requester_id, create_time, create_user_id, update_time, update_user_id', 
                       'safe', 'on' => 'search'),
        );
    }
 
    /**
     * @return array relational rules.
     */
    public function relations() {
        // NOTE: you may need to adjust the relation name and the related
        // class name for the relations automatically generated below.
        return array(
            'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
            'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
            'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
        );
    }
 
    /**
     * @return array customized attribute labels (name=>label)
     */
    public function attributeLabels() {
        return array(
            'id' => 'ID',
            'name' => 'Name',
            'description' => 'Description',
            'project_id' => 'Project',
            'type_id' => 'Type',
            'status_id' => 'Status',
            'owner_id' => 'Owner',
            'requester_id' => 'Requester',
            'create_time' => 'Create Time',
            'create_user_id' => 'Create User',
            'update_time' => 'Update Time',
            'update_user_id' => 'Update User',
        );
    }
 
    /**
     * Retrieves a list of models based on the current search/filter conditions.
     * @return CActiveDataProvider the data provider that can return the models based on 
        the search/filter conditions.
     */
    public function search() {
        // Warning: Please modify the following code to remove attributes that
        // should not be searched.
 
        $criteria = new CDbCriteria;
 
        $criteria->compare('id', $this->id);
        $criteria->compare('name', $this->name, true);
        $criteria->compare('description', $this->description, true);
        $criteria->compare('project_id', $this->project_id);
        $criteria->compare('type_id', $this->type_id);
        $criteria->compare('status_id', $this->status_id);
        $criteria->compare('owner_id', $this->owner_id);
        $criteria->compare('requester_id', $this->requester_id);
        $criteria->compare('create_time', $this->create_time, true);
        $criteria->compare('create_user_id', $this->create_user_id);
        $criteria->compare('update_time', $this->update_time, true);
        $criteria->compare('update_user_id', $this->update_user_id);
 
        return new CActiveDataProvider(get_class($this), array(
            'criteria' => $criteria,
        ));
    }
 
}

创建用户模型类

这对您来说可能已经像是老生常谈了,所以我们把创建用户AR模型类的工作留作练习。下一章中,讨论用户权限和认证的时候,这个类会变得非常重要。

表tbl_project_user_assignment对应的AR类什么时候创建呢?
虽然可以为这个表创建一个AR类,但是这并没有必要。AR模型为我们的应用提供了一个对象关系映射(ORM)层,来帮助我们方便地操作我们的领域对象。然而,ProjectUserAssignment不是一个应用中的领域对象。它只是在关系型数据库中,构建出来,帮助我们建模和管理项目(Project)和用户之间的多对多关系。维护一个专门的AR类来管理这个表,增加了额外的复杂性,我们可以避免在这时候做这样的事。我们可以通过使用Yii的DAO来直接管理这个表的插入、更新和删除来避免额外的维护工作和性能负载。

创建问题(Issue)CRUD操作

现在,AR类已经准备就绪了,我们可以开始创建管理我们项目问题(Issue)必须的功能了。由于项目问题(Issue)的CRUD操作是我们这个迭代要实现的主要目标,我们还是要依赖Gii代码生成工具来帮助我们这些基本的功能。这方面的细节,我们在第5章创建项目的时候已经做过了。在这里,为问题(Issue)创建相关代码的时候,我们会再提醒你一些基本的步骤。

http://localhost/trackstar/index.php?r=gii打开Gii代码生成器的菜单,选择Crud Generator链接。在Model Class表单域中填写Issue。Controller ID中会自动填上Issue。Base Controller Class和Code Template域可以保留他们的默认值。点击Preview按钮,可以得到一个Gii将要创建的代码的列表。下面的截屏显示了这个文件的列表:

您可以点击每个单独的链接来预览将要生成的代码。如果满意了,就点击Generator按钮,创建所有这些文件。您将看到下面的成功消息:

使用问题(Issue)CRUD操作

让我们来试一下吧,点击上个截屏中显示的try it now链接,或者直接访问http://localhost/trackstar/index.php?r=issue,您将看到跟下面的截图相似的一个画面:

创建一个新的问题(Issue)

由于我们还没有创建过任何一个新的问题(Issue),所以列表是空的。那么,让我们来创建一个吧。点击Create Issue链接(如果您被重定向到登录画面,那么使用demo/demo或者admin/admin登录),您将会看到与下面截屏相似的新问题(Issue)输入表单:

看这张输入表单,可以注意到,数据库表中的每一列,都有一个对应的输入域,跟数据库表中定义的一样。然而,定义和创建数据库表时,我们就知道,有些域不是直接输入数据的域,而是表达与其他实体之间的关系的。例如,我们应该使用一个下拉列表,里面填好可能的问题(Issue)类型,让用户选择,而不是放一个可以随便填写的Type输入框。对于Status域来说,也是一样。Owner和Requester域也应该是下拉列表,里面填充的选择项是那些已经被分派了在此问题(Issue)所属项目下工作的用户。此外,所有的问题(Issue)管理,都应该发生在某个特定的项目的上下文环境中。因此,Project域甚至根本不应该出现在表单中。最后,Create Time,Create User,Update Time和Update User都是应该在表单提交时候计算出来的,而不应该由用户直接来填写。

好,现在我们已经识别出了一些这张初始的表单上要更正的内容。第5章中已经提到,由Gii工具自动生成的CRUD脚手架代码,只是我们开始的地方。它不可能满足一个应用中所有功能需求。我们肯定会经识别出许多问题(Issue)创建过程中需要更正的部分。我们会逐一来修改。

添加类型下拉菜单

我们从添加一个问题(Issue)类型下拉菜单开始。

问题(Issue)只有下面三种类型:

  • 错误(Bugs)
  • 功能(Features)
  • 任务(Tasks)

当我们创建一个问题(Issue)时, 我们希望看到的是,一个只包含这三个选项的下拉列表域供用户选择。我们将通过让问题(Issue)模型类自己提供一个可选类型的列表,来实现这一点。你可能已经猜到了,在给问题(Issue)模型AR类添加这个新功能之前,我们首先要写一个测试。

你应该记得,在第5章中,我们添加了一个新的数据库来专门运行我们的测试,叫trackstar_test。我们这么做,是为了确保测试环境不会对开发环境造成不利的影响。所以请先确认你已经用我们先前创建的新表tbl_issue和tbl_user更新了测试数据库。

让测试进入“红色区”

我们已经知道,TDD过程的第一步,是快速地写一个产生失败结果的测试。创建一个新的单元测试文件protected/tests/unit/IssueTest.php并且添加下面这些代码:

class IssueTest extends CDbTestCase {
 
    public function testGetTypes() {
        $options = Issue::model()->typeOptions;
        $this->assertTrue(is_array($options));
    }
 
}

现在打开命令行,运行如下命令执行测试:

phpunit unit/IssueTest.php
PHPUnit 3.3.17 by Sebastian Bergmann.
.E
Time: 0 seconds
There was 1 error:
testGetTypes(IssueTest)
CException: Property "Issue.typeOptions" is not defined.
/YiiRoot/framework/base/CComponent.php:131
/YiiRoot/yii-read-only/framework/db/ar/CActiveRecord.php:107
/Webroot/tasctrak/protected/tests/unit/IssueTest.php:6
 
FAILURES!
Tests: 1, Assertions: 0, Errors: 1.

OK,现在我们已经完成了TDD中的第一步。(那就是快速写一个失败的测试)。测试会失败的原因是非常明显的。在模型类中,并不包含方法Issue::typeOptions();。我们需要添加一个。

从“红色区”到“绿色区”

现在打开AR模型类,在protected/models/Issue.php文件夹里,然后给类添加下面的方法:

/**
 * @return array issue type names indexed by type IDs
 */
public function getTypeOptions() {
    return array();
}

我们已经添加了一个简单的方法,取了一个合适的名字,它返回一个数组类型(尽管现在仍旧是空的)。

现在我们再运行一次测试:

phpunit unit/IssueTest.php
PHPUnit 3.3.17 by Sebastian Bergmann.
..
Time: 0 seconds
OK (1 tests, 1 assertion)

Yii框架的基类使用PHP的__get神奇函数。这允许我们在子类中编写方法,如getTypeOptions(),但是,却可以像使用类属性那样的语法如>typeOptions去访问这个方法。

所以,现在我们的测试会通过的。我们已经进入“绿色区”。这非常棒!但是我们现在实际上并没有返回任何数值。我们当然不能够基于这个空的数组来添加我们的下拉菜单。因为我们有三种基础的问题(Issue)类型,我们使用类常量来将它们映射为整数值,然后我们使用我们的getTypeOptions()方法来返回用户友好的描述词应用到下来菜单中。

再次回到“红色区”

在将这个添加到Issue类之前,让我们的测试再次失败。让我们再添加一个断言来质询返回的数组,并且验证它的内容是我们想要的。我们的测试将保证返回数组有三个元素,并且这些值对应着我们的问题(Issue)类型:错误、功能和任务。将测试修改为:

public function testGetTypes() {
      $options = Issue::model()->typeOptions; 
      $this->assertTrue(is_array($options)); 
      $this->assertTrue(3 == count($options));
      $this->assertTrue(in_array('Bug', $options)); 
      $this->assertTrue(in_array('Feature', $options)); 
      $this->assertTrue(in_array('Task', $options));
}

由于getTypeOptions()方法,仍然返回一个空数组,我们的断言肯定会失败。所以,我们又回到了红色区。让我们往Issue.php中添加代码来使这些断言通过吧。

再次回到“绿色区”

在Issue类的顶部,添加下面三个常量定义:

const TYPE_BUG=0; 
const TYPE_FEATURE=1; 
const TYPE_TASK=2;

然后,修改方法Issue::getTypeOptions()返回一个基于这些常量定义的数组。

public function getTypeOptions()
{
    return array( 
        self::TYPE_BUG=>'Bug', 
        self::TYPE_FEATURE=>'Feature', 
        self::TYPE_TASK=>'Task',
    );
}

现在如果我们再运行一次测试,我们所有5个断言都回通过,然后我们又回到了绿色区。

phpunit unit/IssueTest.php
PHPUnit 3.3.17 by Sebastian Bergmann.
..
Time: 0 seconds
OK (1 tests, 5 assertions)

我们现在让我们的模型类返回了需要的问题(Issue)类型,但是我们的表单中还没有一个下拉列表域来使用这些值。现在让我们来添加。

添加问题(Issue)类型下拉选择框

打开新建问题(Issue)表单视图,protected/views/issue/_form.php,找到与类型对应的表单域:

<div class="row"> 
    <?php echo $form->labelEx($model,'type_id'); ?> 
    <?php echo $form->textField($model,'type_id'); ?> 
    <?php echo $form->error($model,'type_id'); ?>
</div>

这几行需要明确含义。为了理解这个,我们需要参考在_form.php的顶部的一些代码:

<?php $form=$this->beginWidget('CActiveForm', array( 
    'id'=>'issue-form', 
    'enableAjaxValidation'=>false,
)); ?>

这段代码定义了一个变量$form,它是一个Yii的CActiveForm挂件(Widget)。第9章会介绍挂件(Widget)的更多细节。现在,我们只要更好地理解CActiveForm,就能理解这些代码。它可以被认为是一个助手类,提供了一组方法帮助我们创建一个与数据模型类关联的数据输入表单。在这,模型类指的就是问题(Issue)模型类。

为了完全理解视图文件中的变量,让我们回顾一下产生了视图文件的控制器代码。你应该记得,将数据从控制器传递到视图的一个方法是明确声明一个数组,数组的键,是在视图文件中可用的变量的名称。由于这是一个新问题(Issue)的创建动作,生成表单的控制器方法是IssueController::actionCreate()。这个方法在下面列出来了:

public function actionCreate() {
    $model=new Issue;
    // Uncomment the following line if AJAX validation is needed
    // $this->performAjaxValidation($model);
    if(isset($_POST['Issue'])) {
        $model->attributes=$_POST['Issue'];
        if($model->save())
        $this->redirect(array('view','id'=>$model->id));
    }
    $this->render('create',array(
        'model'=>$model,
    ));
}

这里我们看到,生成视图时,问题(Issue)模型类的一个实例被传送了,可以通过一个叫$model的变量访问到。

好的,现在让我们回到创建新问题(Issue)项表单中负责生成Type域的代码。第一行是:

$form->labelEx($model,'type_id');

这一行使用CActiveForm::labelEx()为问题(Issue)模型的一个属性type_id生成一个HTML标签。它接受一个模型类的实例和对应的模型属性名来生成我们要的标签。模型类Issue::attributeLabels()方法被用来决定标签。找到这个方法,我们看到属性type_id映射到一个标签Type,正是我们看到的这个表单域的标签。

public function attributeLabels() {
    return array(
        'id' => 'ID',
        'name' => 'Name',
        'description' => 'Description',
        'project_id' => 'Project',
        'type_id' => 'Type',
        'status_id' => 'Status',
        'owner_id' => 'Owner',
        'requester_id' => 'Requester',
        'create_time' => 'Create Time',
        'create_user_id' => 'Create User',
        'update_time' => 'Update Time',
        'update_user_id' => 'Update User',
    );
}

下一行代码是:

<?php echo $form->textField($model,'type_id'); ?>

它使用了CActiveForm::textField()方法,来给我们的问题(Issue)模型属性生成一个文本输入域。所有的为type_id定义的验证都在类方法Issue::rules()中,他们会作为表单验证规则应用到输入表单上。

最后一行代码如下:

<?php echo $form->error($model,'type_id'); ?>

它使用了CActiveForm::error()方法来生成提交时,与特定type_id相关的属性的验证错误。使用这种方式,错误消息会直接显示在域的下方。

你可以使用Type域来尝试一下这个验证。因为type_id列在我们的MySQL数据库中被定义成了一个整数,Gii生成的问题(Issue)模型类在Issue::rules()方法中,有一个验证规则,来保证这个约束:

public function rules() {
    // NOTE: you should only define rules for those attributes that
    // will receive user inputs.
    return array(
        array('name', 'required'),
        array('project_id, type_id, status_id, owner_id, requester_id, create_user_id, 
                    update_user_id', 'numerical', 'integerOnly' => true),
        array('name', 'length', 'max' => 256),
        array('description', 'length', 'max' => 2000),
        array('create_time, update_time', 'safe'),
        // The following rule is used by search().
        // Please remove those attributes that should not be searched.
        array('id, name, description, project_id, type_id, status_id, owner_id, requester_id, 
                   create_time, create_user_id, update_time, update_user_id', 
                   'safe', 'on' => 'search'),
    );
}

所以,如果我们试图提交一个字符串值到Type表单域,我们会看到一个行内错误,在域的右下方,像下面的截图显示的一样:

现在我们已经明白了我们有什么,那我们就有了一个更好的基础来改变它。我们需要做的事情是,我们要把这个表单域从一个允许自由输入的文本域变成一个下拉选项类型。可能让你有点惊讶,CActiveForm类有一个dropDownList()方法,可以根据模型属性生成一个下来列表。所以,让我们使用下面的代码替换$text->textField的那一行:

<?php echo $form->dropDownList($model,'type_id', $model->getTypeOptions()); ?>

这个方法将采用相同的模型作为第一个参数,模型属性作为第二个参数。第三个参数确定了下拉的选项。这应该是一个数组,由value=>display对组成。在Issue模型中,我们已经创建了我们的getTypeOptions()方法,来返回这个格式的数组,所以我们现在可以直接使用。保存你的工作,然后我们再看一次我们的问题(Issue)输入表单。你可以看到一个很好的下拉列表,里面有类型的选项,出现在原来自由输入文本域的位置,就像下面的图显示的一样。

自己动手添加状态下拉菜单

我们在处理问题(Issue)状态的时候,要采用相同的方法。就像第3章提到的那样,当我们将一个问题(Issue)引入到应用中来,它可以是三种状态中的一种:

  • 还没有开始
  • 已经开始了
  • 已经完成

我们将创建状态下拉列表的操作留给读者。在使用了我们处理类型时候相同的方法后,(我们希望你已经测试了第一个方法),并且类型和状态表单域应该已经变成了下拉列表的形式。表单应该看起来像下面的截图一样:

修复所有者和请求者字段域

我们先前就已经注意到了问题(Issue)创建表单的另一个问题,所有者和请求者域还是自由填写的文本域。然而,我们知道,这两个域应该是整数,代表的是tbl_user表的外键。所以,我们还需要为这些域添加下拉菜单。我们将不会使用与创建类型和状态属性时相同的方法,因为问题(Issue)的所有者和请求者需要从表tbl_user中取到。让事情稍微复杂一点,因为并不是系统中的所有用户都在问题(Issue)所在的项目(Project)中,所以,我们不能使用从整个tbl_user表中取出的数据来填充下拉表单。我们要将列表限制在一个与这个项目(Project)相关联的用户范围内。

这就引出了另一个我们需要解决的问题。像迭代计划中说的那样,我们需要在一个特定项目的上下文中来管理我们的问题(Issue)。也就是说,甚至在我们选定了一个特定的项目(Project)之前,你都无法看到创建问题(Issue)的表单。目前,我们的应用功能并不支持这个工作流程。

让我们逐一来解决这些问题。首先,我们将修改应用,来强制在管理操作与项目(Project)相关的问题(Issue)之前,必须先选中一个有效的项目(Project)。一旦一个项目(Project)被选定,我们将确保所有者和请求者下拉列表中的选择项,只能是与这个项目(Project)关联起来的用户。

强制选择一个项目(Project)上下文

我们想要确保在任何与问题(Issue)相关的功能被操作之前,必须处于一个有效的项目(Project)上下文中。为了做到这一点,要实现一个过滤器。Yii中的过滤器是指,通过配置,在一个控制器的动作被执行之前或者之后执行的一小段代码。一个普遍的例子就是,当我们要求执行某个特定的控制器动作之前,用户必须已经登录,那么可以写一个简单的访问过滤器在这个动作执行之前来检查这个要求。另一个例子是,如果我们想要在某个动作执行后额外记录些什么,或者执行一些审核逻辑,可以编写一个简单的审计过滤器来进行这个动作之后的处理任务。

在这个案例中,我们想要保证一个有效的项目(Project)必须在创建一个问题(Issue)之前被选中。所以,需要添加一个项目(Project)过滤器到IssueController类中来实现。

实现一个过滤器

一个过滤器可以被定义为一个控制器类的方法,或者单独建立一个类。当使用前者时,过滤器方法的命名必须以filter开头,并且有一个特定的签名。例如,如果我们想要创建一个过滤器方法,叫SomeMethodName,我们的完整过滤器方法将像这样:

public function filterSomeMethodName($filterChain) 
{
     ...
}

另一个创建过滤器的方法是写一个单独的类来实现过滤器的逻辑。使用此种方法时,类必须继承自CFilter,并且至少要重载preFilter()和postFilter()两个方法中的一个,具体取决于逻辑应该在动作被调用之前执行还是之后执行。

添加一个过滤器

现在,让我们添加一个过滤器到IssueController类中,来处理有效的项目(Project)。现在先使用最简单的方法,添加一个以filter开头的方法到类里。因为这个方法的调用是Yii框架自动执行的,所以,我们很难使用先行测试的流程来实现这个方法。在这个案例里面,我们会稍微打破一下惯例,添加个方法之前,我们不会写测试。

打开proteced/controllers/IssueController.php并且添加下面的方法到类的底部:

public function filterProjectContext($filterChain) 
{
    $filterChain->run();
}

好了,现在我们已经定义了一个过滤器,但是却并没有做太多事情。它只是执行了$filterChain->run(),这个操作将继续进行过滤处理,并允许执行使用了这个过滤器的操作方法。这就带来了另一个问题:我们怎样定义指定的操作方法使用这个过滤器?

指定过滤动作

如果我们要指定在哪个动作方法中,应该应用过滤器,需要重载Yii框架中控制器的基类CController的方法filters()。事实上,这个方法已经在IssueController类中被重载了。是在我们使用Gii工具自动生成这个类的时候,就已经完成了。它已经帮我们添加了一个简单的访问控制过滤器accessControl,这个方法在CController基类中已经被定义过了,用来处理一些基本的验证以保证用户有足够的权限来执行特定的动作。我们将会在下一章中涉及到用户验证和鉴权。现在,我们只需要把这个过滤器添加到过滤器配置数组。为了让我们的新过滤器应用到创建动作,添加如下高亮处代码到IssueController::filters()方法中:

/**
 * @return array action filters
 */
public function filters()
{
    return array(
        'accessControl', //perform access control for CRUD operations
        'projectContext + create', //check to ensure valid project context
    );
}

filters()方法会返回一个包含了过滤器配置的数组。前面的方法返回了一个配置,指定了projectContext过滤器,该过滤器是在类中定义的方法,应用到actionCreate()上。这个配置的语法,允许“+”号和“-”号,用来指定是否要应用到一个方法上。例如,如果我们决定要将这个过滤器应用到除了actionUpdate()和actionView()之外的所有动作上,我们可以这样写:

return array( 
    'projectContext - update, view' ,
);

你不能同时使用加号和减号。在任何给出的过滤器配置中,只能使用一个。加号操作符意味着“只在下列动作中使用过滤器”。减号则意味着“在除了下列动作之外的所有动作中应用过滤器”。如果既不使用‘+’,也不使用‘-’,那么过滤器会被应用到所有的动作中。

此时,我们将只限制创建动作。所以,先前用 + create来配置过滤器,我们的过滤器方法将在任何用户试图创建一个问题(Issue)时候被调用。

添加一些过滤器逻辑

好,现在我们已经定义了一个过滤器了,并且,我们配置了在IssueController类中actionCreate()方法被调用的时候调用此过滤器。然而,它还是不能提供必须的逻辑。我们希望确保在执行上述动作之前,处于当前项目(Project)环境,所以,我们需要在$filterChain->run()方法运行之前,把相应的逻辑放入到这个过滤器方法中。

我们将会在控制器类中添加一个项目(Project)属性。我们将会在URL地址中使用一个查询参数来作为项目(Project)标识符。动作前过滤器会检查存在的项目(Project)属性是否为空。如果不为空,它会使用查询参数作为主键标识符找到项目(Project)。如果成功,动作会被执行,如果失败,会抛出一个异常。下面是IssueController中实现上述功能所必须的代码。

class IssueController extends CController {
....
 
    /**
     * @var private property containing the associated Project model instance.
     */
    private $_project = null;
 
    /**
     * Protected method to load the associated Project model class
     * @project_id the primary identifier of the associated Project
     * @return object the Project data model based on the primary key
     */
    protected function loadProject($project_id) {
    //if the project property is null, create it based on input id
        if ($this->_project === null) {
            $this->_project = Project::model()->findbyPk($project_id);
            if ($this->_project === null) {
                throw new CHttpException(404, 'The requested project does not exist.');
            }
        }
        return $this->_project;
    }
 
    /**
     * In-class defined filter method, configured for use in the above filters() method
     * It is called before the actionCreate() action method is run inorder to ensure a proper project context
     */
    public function filterProjectContext($filterChain) {
        //set the project identifier based on either the GET or POST input
        //request variables, since we allow both types for our actions
        $projectId = null;
        if (isset($_GET['pid']))
            $projectId = $_GET['pid'];
        else
            if (isset($_POST['pid']))
                $projectId = $_POST['pid'];
 
        $this->loadProject($projectId);
 
        //complete the running of other filters and execute the requested action
        $filterChain->run();
    }
...
}

在这里,在问题(Issue)列表页面点击创建问题(Issue)链接(http://hostname/trackstar/index.php?r=issue/list),尝试去创建一个新的问题(Issue).

你会遇到一个404错误页面,而且会看到刚才我们定义的错误信息,请求的项目(Project)是不存在的。

这很好。它说明我们已经恰当地实现了阻止当没有项目(Project)被指定时创建问题(Issue)的动作的代码。要避免这个错误的最简单的方法,是在创建一个新的问题(Issue)时,在URL中添加一个参数来指定pid。让我们来试一下这样做,来提供一个有效的项目(Project)标识符吧,并且处理表单来创建一个新的问题(Issue)。

添加项目ID

回到第五章,当我们测试和实现项目(Project)的CRUD操作时,我们创建了好几个新的项目(Project)。所以,看起来在开发数据库中,你已经有了一个有效的项目ID了。如果没有,使用应用再次创建一个新的项目。一旦完成,注意一下项目的ID,我们需要将这个ID写到新建问题(Issue)URL中。

我们需要修改的链接在问题(Issue)列表页面对应的试图当中:/protected/views/issue/index.php。在那个文件的顶部,你将看到新建的链接在菜单中被指定,如下方高亮的代码:

$this->menu=array(
    array('label'=>'Create Issue', 'url'=>array('create')),
    array('label'=>'Manage Issue', 'url'=>array('admin')),
);

为了添加一个查询参数到则个链接中,我们只是简单地添加一个name=>value键值对到定义url的数组中。我们为过滤器添加的代码,查询参数的名字叫pid(表示项目ID)。还有,因为我们使用第一个(project ID = 1)项目(Project)来举这个例子,我们要将新建问题(Issue)这个链接该成如下样子:

array('label'=>'Create Issue', 'url'=>array('create', 'pid'=>1)),

现在当你再查看问题(Issue)列表页面的时候,你会看到新建问题(Issue)的超链接的URL已经带有了一个查询参数:http://localhost/trackstar/index.php?r=issue/create&pid=1

查询参数允许过滤器正确地设定项目(Project)上下文。所以,现在你再点击超链接,不再会显示404页面,而是打开了问题(Issue)创建的表单。

修改项目详情页面

在创建新问题(Issue)的链接中添加了项目(Project)ID到URL中作为参数,是确保我们的过滤器像我们希望的那样工作的很好的第一步。然而,现在我们已经硬编码了那个链接,使得我们总是将新的问题(Issue)与项目ID为1的项目关联起来。当然,这不是我们想要的。我们想要的是创建新问题(Issue)的链接成为项目(Project)详情页面的一部分。这样,当你从项目(Project)列表中选定一个项目(Project)的时候,就可以知道项目(Project)上下文,我们可以自动地把项目(Project)的ID追加到新建问题(Issue)的链接的末尾,现在让我们来实现它。

打开项目(Project)详情对应的试图,/protected/views/project/view.php,在这个文件的顶部,你将注意到菜单项包含在了$this->menu这个数组中。我们需要再添加一个创建新问题(Issue)的链接到这个列表的末尾,我们这样定义这个链接:

$this->menu = array(
    array('label' => 'List Project', 'url' => array('index')),
    array('label' => 'Create Project', 'url' => array('create')),
    array('label' => 'Update Project', 'url' => array('update', 'id' => $model->id)),
    array('label' => 'Delete Project', 'url' => '#', 'linkOptions' => array('submit' => array('delete', 'id' => $model->id), 'confirm' => 'Are you sure you want to delete this item?')),
    array('label' => 'Manage Project', 'url' => array('admin')),
    array('label' => 'Create Issue', 'url' => array('issue/create', 'pid' => $model->id)), //&lt;---注意,这一行
);

刚才我们做的,已经将创建新问题(Issue)的菜单选项移到了一个特定项目(Project)的详情页面中。我们使用了一个与先前相似的超链接,但是这次,我们指定了完整的控制器/动作对(issue/create)。而且,这次我们没有将项目的ID硬编码为1,而是在这个视图文件中使用了$model变量,它对应着特定的项目的AR对象。使用这种方式,不用考虑我们选择的项目,这个变量总是会给出正确的项目id属性。

移除项目输入表单域

现在,当我们要创建一个新的问题(Issue)时,已经正确设定了项目(Project)上下文,我们可以从表单中移除项目(Project)表单域。但是,我们还是需要一个项目(Project)ID与表单一起提交。因为我们在生成这个表单之前,已经知道了项目(Project)的ID,所以我们可以在创建动作中设定项目(Project)模型的属性。使用这种方法,传给视图文件的$model实例将会已经带有恰当的项目(Project)ID了。

首先,让我们来修改IssueController::actionCreate()方法,在其以创建后,就为问题(Issue)模型的实例指定一个project_id属性:

public function actionCreate() {
    $model=new Issue;
    $model->project_id = $this->_project->id;
    ...
}

现在表单文件中,project_id属性已经设定了。

打开新问题(Issue)表单的视图文件,/protected/views/issue/_form.php。移除下列与项目(Project)输入域关联的几行代码:

<div class="row"> 
    <?php echo $form->labelEx($model,'project_id'); ?> 
    <?php echo $form->textField($model,'project_id'); ?> 
    <?php echo $form->error($model,'project_id'); ?>
</div>

将它们替换为一个隐藏域:

<div class="row"> 
    <?php echo $form->hiddenField($model,'project_id'); ?>
</div>

现在当我们提交表单的时候,project_id属性会被正确地设定。尽管我们现在还没有设定我们的所有者和请求者下拉列表,我们已经可以提交表单来创建一个正确设定了项目(Project)ID的问题(Issue)了。

回到所有者和请求者下拉域

最终,我们回到我们起初想要做的,将所有者和请求者两个表单域改成下拉选项,里面包含着这个项目(Project)的有效成员。为了正确的完成这些,我们需要给一个项目(Project)关联一些用户。因为在第7章和第8章中会介绍用户管理,所以这里,我们将会通过SQL语句直接在数据库中添加一些关联。在我们早先的DDL语句中,我们已经添加了两个新的测试用户作为我们的种子数据。为了提醒一下,那个insert语句在下面:

INSERT INTO `tbl_user`
(`email`, `username`, `password`)
VALUES
('test1@notanaddress.com','Test_User_One', MD5('test1')),
('test2@notanaddress.com','Test_User_Two', MD5('test2'));

这段代码在我们的系统中创建了两个ID为1和2的新用户。让我们手动将这两个用户分派给项目(Project)#1。

为了做到这一点,在你的trackstar_dev和trackstar_test数据库中,运行下面的insert语句:

INSERT INTO `tbl_project_user_assignment` (`project_id`, `user_id`) VALUES (1,1), (1,2);

运行了前面所述的SQL语句后,我们已经有了两个有效的成员分配给了项目(Project)#1。

Yii中关系型活动记录一个比较神奇的特性是,直接从问题(Issue)$model的实例中访问问题(Issue)所属项目的有效成员的能力。当我们使用Gii工具初始创建我们的问题(Issue)模型类时,它足够聪明地查看下面的数据库并且创建相关的关系。可以从relations()方法中看到,在/protected/models/Issue.php文件中可以查看。因为我们在创建了恰当的数据库关系后,才创建的这个类,所以,方法应该看起来是这样的:

/**
 * @return array relational rules.
 */
public function relations() {
    // NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
        'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
        'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
        'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
    );
}

就像 NOTE 中建议的那样,你的属性名称可能有轻微不同,请按照需要将它们调整好。这个数组配置定义了模型实例的属性,而这几个属性本身也是其他的AR实例。这里有了这些关系,我们可以相当简单地访问相关AR实例。例如说,我们想要访问与问题(Issue)关联的项目模型类。我们可以通过使用下面的语法来做到这一点:

//create the model instance by primary key:
$model = Issue::model()->findbyPk(1);
//access the associated Project AR instance
$project = $model->project;

现在,因为我们在数据库中定义其他的表和关系之前就已经建立了我们的项目(Project)模型类,所以关系还没有定义。然而,现在我们已经定义了一些关系,我们需要将这些关系添加到Project::relations()方法中。打开项目(Project)AR类文件/protected/models/Project.php,使用下面的代码替换整个relations()方法:

/**
 * @return array relational rules.
 */
public function relations() {
    // NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
        'issues' => array(self::HAS_MANY, 'Issue', 'project_id'),
        'users' => array(self::MANY_MANY, 'User', 
                                     'tbl_project_user_assignment(project_id, user_id)'),
    );
}

有了这些,我们可以相当简单的访问所有的与项目(Project)关联的问题(Issue)和/或用户:

//create the Project model instance by primary key:
$model = Project::model()->findbyPk(1);
//get an array of all associated Issue AR instances
$allIssues = $model->issues;
//get an array of all associated User AR instance
$allUsers = $model->users;
//get the User AR instance representing the owner of
//the first issue associated with this project
$ownerOfFirstIssue = $model->issues[0]->owner;

通常我们需要写复杂的SQL join语句来访问这样的关系型数据。而使用了Yii中的关系型AR,将我们从这种复杂漫长痛苦的过程中解放出来。我们现在可以使用优雅精简的面向对象语法来访问这些关系了。

生成数据来填充下拉菜单

现在,我们已经知道了数个方法,都可以生成填充“所有者”和“请求者”下拉选项的数据。我们将使用一个与生成状态和类型下拉数据时相似的方法,并且把逻辑放到模型类中。在这个例子中,项目AR类是主角,因为一个有效的用户是与一个项目关联的,而不是与一个问题(Issue)关联。

因为我们将要添加一个公用方法到我们的项目AR类中,所以我们可以再一次地使用TDD方法了。好,让我们快点写一个失败的测试吧。

再一次,请记住我们已经设定了一个trackstar_test数据库用于测试。如果你一直实践着上面的代码,请确保这个测试数据库的结构和trackstar_dev数据库保持着一致。

打开 /protected/tests/unit/ProjectTest.php文件添加如下测试:

public function testGetUserOptions() 
{
    $project = $this->projects('project1'); 
    $options = $project->userOptions;
    $this->assertTrue(is_array($options));
}

现在运行测试:

>>phpunit unit/ProjectTest.php 
PHPUnit 3.3.17 by Sebastian Bergmann. 
....E 
Time: 0 seconds There was 1 error: 
1) ProjectTest::testGetUserOptions 
CException: Property "Project.userOptions" is not defined.... 
FAILURES! 
Tests: 5, Assertions: 10, Errors: 1.

好,我们有了一个没有通过的测试。它失败的原因很显然,因为我们测试了一个在项目AR类中并不存在的方法。所以,让我们来添加它。打开/protected/models/Project.php文件,添加下列方法到类的底部:

/**
 * @return array of valid users for this project, indexed by user IDs
 */
public function getUserOptions() 
{
    $usersArray = array(); return $usersArray;
}

如果我们再运行一次测试,会看到我们又回到了“绿色区”。然而,我们只有一个返回空数组的方法。我们需要的是一个有效的用户数组来填充表单里的下拉选项。让我们修改测试,确保返回的数组包含的元素数量大于0,来让我们的测试再次变成“红色区”。

将测试改成下面的样子:

public function testGetUserOptions() 
{
    $project = $this->projects('project1'); 
    $options = $project->userOptions; 
    $this->assertTrue(is_array($options)); 
    $this->assertTrue(count($options) > 0);
}

再次运行测试,应该看到如下的错误结果:

There was 1 failure:
1) ProjectTest::testGetUserOptions 
Failed asserting that <boolean:false> is true.

所以,让我们再次回到Project::getUserOptions()方法,返回一些实际的用户。将方法修改成:

public function getUserOptions() 
{
    $usersArray = CHtml::listData($this->users, 'id', 'username'); 
    return $usersArray;
}

这里我们使用了Yii的CHtml助手类帮助我们创建一个数组包含了id=>uesrname键值对表达的与项目(Project)关联的每个用户。还记得Project类的users属性映射到了一个用户AR实例的数组上。CHtml::listData()方法可以利用这个列表并且生成一个适合CActiveForm::dropDownList()使用的有效的数组。现在,只要我们记得用我们两个用户以及与他们关联的项目(Project)#1来填充我们的测试数据库,我们测试将会顺利通过。

添加“用户”和“用户项目关系表”夹具

我们的测试现在通过了,这只是因为我们显式添加了用户,并且我们还显式地添加了条目到项目(Project)关联表中。如果有人过来移除了这些条目会怎么样呢?我们需要修复这个脆弱的关系。我们已经知道,测试夹具正是我们需要来确保与我们的测试涉及到的数据库数据可以以一种比较稳定的形式重复运行的东西。我们曾经为我们的项目(Project)数据做过相同的事情。我们需要为与tbl_user和tbl_project_user_assignment表关联的数据再做一次。

创建一个文件,/protected/tests/fixtures/tbl_user.php,并且添加如下代码:

return array(
    'user1' => array(
        'email' => 'test1@notanaddress.com',
        'username' => 'Test_User_One',
        'password' => MD5('test1'),
        'last_login_time' => '',
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
    'user2' => array(
        'email' => 'test2@notanaddress.com',
        'username' => 'Test_User_Two',
        'password' => MD5('test2'),
        'last_login_time' => '',
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
);

这与我们通过显式SQL手动添加的数据是相同的内容,但是这里它们被表示成了固定的数据。

我们需要为我们的关联表做相同的事情。创建一个新文件,/protected/tests/fixtures/tbl_project_user_assignment.php并且添加如下代码:

return array(
    'user1ToProject1' => array(
        'project_id' => 1,
        'user_id' => 1,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
    'user2ToProject1' => array(
        'project_id' => 1,
        'user_id' => 2,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
);

这与我们手动添加到tbl_project_user_assignment表中的数据相同,只是被表达成了固定的数据。

现在我们需要将这些夹具添加到单元测试中。打开ProjectTest文件,/protected/tests/unit/ProjectTest.php,将它添加到文件顶部夹具的定义中,如下方高亮代码示意的一样:

public $fixtures = array(
    'projects' => 'Project',
    'users' => 'User',
    'projUsrAssign' => ':tbl_project_user_assignment',
);

注意当映射到表tbl_project_user_assignment时,我们必须添加“:”。这用来表示这是一个数据库表,而不是一个AR模型类。

现在这些已经被添加了,每次我们运行测试ProjectTest.php的时候,我们的tbl_user和tbl_project_user_assignment表将被在夹具中定义的数据重置为一致的状态。

现在让我们再运行一次跟项目(Project)有关的测试:

>> unit/ProjectTest.php 
PHPUnit 3.4.12 by Sebastian Bergmann.
..... Time: 0 seconds 
OK (5 tests, 12 assertions)

我们仍然通过了测试,但是现在他们使用的夹具中的数据了。

现在我们已经有了我们的getUserOptions()方法了,我们需要实现下拉列表来它显示返回的数据。我们已经添加了一个私有的$_project属性到我们的IssueController类中。这个属性包含了有效的项目(Project)上下文。我们需要在视图文件中访问这个相同的项目(Project)属性来显示输入表单。所以,我们需要添加一个简单的getter方法来将这个私有属性给暴露出来。添加下列方法到IssueController类的底部:

/**
   * Returns the project model instance to which this issue belongs
   */
public function getProject() {
    return $this->_project;
}

现在,打开包含了输入表单的视图文件,/protected/views/issue/_form.php,找到owner_id和requester_id这两个文字输入表单域元素定义的地方。

替换

<?php echo $form->textField($model,'owner_id'); ?>

为以下代码:

<?php echo $form->dropDownList($model,'owner_id', $this->getProject()->getUserOptions()); ?>

再替换这行

<?php echo $form->textField($model,'requester_id'); ?>

为以下代码

<?php echo $form->dropDownList($model,'requester_id', $this->getProject()->getUserOptions()); ?>

现在如果我们再一次查看问题(Issue)创建表单,我们看到所有者和请求者两个域已经变成了两个很漂亮的下拉表单域。

做最后一个修改

因为我们已经打开了创建问题(Issue)的表单视图文件,让我们来快速地做最后一个修改。创建时间和用户,以及最后修改时间和用户是我们用来记录修改历史和审计用的,不应该暴露给用户。最后,我们将修改应用逻辑,根据问题(Issue)的插入动作和更新动作来自动填写这些域。现在,只是将他们对应的表单输入域移除。

简单地将下列代码从文件/protected/views/issue/_form.php中移除:

<div class="row"> 
    <?php echo $form->labelEx($model,'create_time'); ?> 
    <?php echo $form->textField($model,'create_time'); ?> 
    <?php echo $form->error($model,'create_time'); ?>
</div>
 
<div class="row"> 
    <?php echo $form->labelEx($model,'create_user_id'); ?> 
    <?php echo $form->textField($model,'create_user_id'); ?> 
    <?php echo $form->error($model,'create_user_id'); ?>
</div>
 
<div class="row"> 
    <?php echo $form->labelEx($model,'update_time'); ?> 
    <?php echo $form->textField($model,'update_time'); ?> 
    <?php echo $form->error($model,'update_time'); ?>
</div>
 
<div class="row"> 
    <?php echo $form->labelEx($model,'update_user_id'); ?> 
    <?php echo $form->textField($model,'update_user_id'); ?> 
    <?php echo $form->error($model,'update_user_id'); ?>
</div>

下列截图显示了我们的新问题(Issue)创建表单现在的样子:

完成剩下的CRUD操作

这一个迭代的目标是实现问题(Issue)的所有CRUD操作。我们已经将创建功能完成了,但是我们仍然需要来完成读,更新和删除问题(Issue)的操作。幸运的是,所有的基础已经被Gii的代码生成功能完成了。然而,因为我们想要问题(Issue)的所有操作都在项目(Project)的上下文中来完成,我们需要做一些修改来决定我们如何访问这些功能。

列出问题(Issue)

尽管在IssueController类中已经有了actionIndex()方法能显示数据库中的问题(Issue)列表,我们不需要这个目前已经写好的功能。我们想要一个只列出与特定项目(Project)相关的问题(Issue)的页面,而不是一个列出数据库中所有的问题(Issue)的列表。所以,我们修改应用来让项目(Project)页面的一部分显示问题(Issue)的列表。因为我们使用了关系型AR模型,这个修改将会非常简单。

修改ProjectController

首先,让我们修改ProjectController类中的actionView()方法。因为我们想显示一个与项目(Project)相关的问题(Issue)的列表,我们可以它显示在项目(Project)详情页面上。方法actionView()是显示项目(Project)详情的方法。

将那个方法修改成:

/**
 * Displays a particular model.
 */
public function actionView() {
    $id = $_GET['id'];
    $issueDataProvider = new CActiveDataProvider('Issue', array(
                'criteria' => array(
                    'condition' => 'project_id=:projectId',
                    'params' => array(
                        ':projectId' => $this->loadModel($id)->id),
                ),
                'pagination' => array('pageSize' => 1),
    ));
    $this->render('view', array(
        'model' => $this->loadModel($id),
        'issueDataProvider' => $issueDataProvider,
    ));
}

这里,我们使用了CActiveDataProvider框架类来为ActiveRecord对象提供数据。它将使用关联的AR模型类从数据库中取数据,并且可以很容易的被Zii挂件CListView简单地以列表的形式显示在视图文件中。我们已经使用了criteria属性来指定条件,它只能取与当前显示的项目相关的问题(Issue)。我们已经使用了pagination属性来限制问题(Issue)列表每页只显示一个问题(Issue)。我们设定了如此低的一个数字,是为了只要添加两个问题(Issue),我们就能快速地看到分页的特性。我们马上演示这个特性。

最后的事情我们要做的就是,将这个数据提供者$issueDataProvider添加到render()方法的参数中,好让视图文件能访问到。

修改项目(Project)视图文件

我们将使用Zii挂件CListView来显示我们的问题(Issue)列表在项目(Project)详情页面上。打开文件/protected/views/project/view.php,然后添加下面代码到文件底下:

<br> 
<h1>Project Issues</h1> 
 
<?php $this->widget('zii.widgets.CListView', array(
            'dataProvider'=>$issueDataProvider,
            'itemView'=>'/issue/_view'
        ));
?>

这里我们将CListView的dataProvider属性设为刚才我们创建的问题(Issue)数据提供者。然后我们配置它使用/protected/views/issue/_views.php文件作为模板为数据提供者中的每个数据生成列表项。这个文件在我们使用Gii工具创建问题(Issue)的CRUD操作的时候,就已经被创建了。我们只是使用它来在项目(Project)详情页面显示问题(Issue)。

我们还需要对文件/protected/views/issue/_view.php做一些修改,我们要为每个问题(Issue)指定一个布局模板。将那个文件的整个内容修改成如下的样子:

<div class="view"> 
    <b><?php echo CHtml::encode($data->getAttributeLabel('name')); ?>:</b> 
    <?php echo CHtml::link(CHtml::encode($data->name), array('issue/view', 'id' => $data->id)); ?>
    <br />
    <b><?php echo CHtml::encode($data->getAttributeLabel('description')); ?>:</b> 
    <?php echo CHtml::encode($data->description); ?>
    <br />
    <b><?php echo CHtml::encode($data->getAttributeLabel('type_ id')); ?>:</b> 
    <?php echo CHtml::encode($data->type_id); ?>
    <br />
    <b><?php echo CHtml::encode($data->getAttributeLabel('status_ id')); ?>:</b> 
    <?php echo CHtml::encode($data->status_id); ?>
</div>

现在,如果我们保存,并且查看我们的项目(Project)详情页面,项目(Project)1(http://localhost/trackstar/index.php?r=project/view&id=1),假如你已经创建了几个问题(Issue)在你的项目(Project)下,你将会看到下面的页面:

因为我们给数据提供者的pagination属性设定了非常低的值(1),我们可以再添加一个问题(Issue)来展示内建的分页功能。再添加一个问题(Issue),会让问题(Issue)的显示增加链接允许我们按照页面来访问项目(Project)的问题(Issue)列表,像下面的截屏描述的那样:

做一些最后的调整

现在,在项目(Project)详情页面,我们已经可以展示与项目(Project)相关的问题(Issue)列表了。我们还可以查看问题(Issue)的详情,还有更新和删除问题(Issue)。所以,CRUD的绝大部分功能都已经就位了。

然而,在我们结束这个迭代之前,还是有一些问题(Issue)需要解决。我们可以注意到,问题(Issue)列表使用数字ID来显示类型,状态,所有者和请求者域。我们应该把他们改成对应的文字值。还有,因为问题(Issue)已经在某个项目(Project)下面了,那么在问题(Issue)的列表上,显示项目(Project)的ID就有点多余了。所以,我们可以把这个移除掉。最后,我们需要解决一些其他表单页面的导航链接,使它们总是能回到项目(Project)详情页面来开始用户的问题(Issue)管理。

我们将在接下来解决。

使状态和类型文字显示出来

先前,我们已经往问题(Issue)AR类中添加了公有方法来取得状态和类型选项,来填充问题(Issue)创建表单的下拉选项。我们还需要在这个AR类中添加类似的方法来返回特定标识符的问题(Issue)来显示我们的问题(Issue)列表。

因为这些方法是问题(Issue)AR模型类的公有方法,我们将在使用TDD方法来实现它。为了让事情进展的更快一点,我们将这两者放到一起来做。而且,我们将TDD放一下,我们将开始一个更大的步骤。我们可以总是返回一个更紧凑的方法。

首先,我们需要添加一些夹具数据来确保我们有一些问题(Issue)与一个项目(Project)关联起来。我们还要确保我们的问题(Issue)测试使用了项目(Project)夹具数据同时,这个问题(Issue)属于这个项目(Project)。

首先,为问题(Issue)添加一个新的夹具数据文件,/protected/fixtures/tbl_issue.php,然后添加下列内容:

return array('issueBug' => array(
        'name' => 'Test Bug 1',
        'description' => 'This is test bug for project 1',
        'project_id' => 1,
        'type_id' => 0,
        'status_id' => 1,
        'owner_id' => 1,
        'requester_id' => 2,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
    'issueFeature' => array(
        'name' => 'Test Bug 2',
        'description' => 'This is test bug for project 2',
        'project_id' => 2,
        'type_id' => 1,
        'status_id' => 0,
        'owner_id' => 2,
        'requester_id' => 1,
        'create_time' => '',
        'create_user_id' => '',
        'update_time' => '',
        'update_user_id' => '',
    ),
);

现在我们需要配置我们的IssueTest类使用一些夹具数据。将下列夹具数组添加到问题(Issue)测试类的顶部:

public $fixtures=array(
            'projects'=>'Project', 
            'issues'=>'Issue',
);

有了我们的夹具数据就位后,我们可以添加两个新的测试到IssueTest单元测试中,用来测试状态和类型的文字:

public function testGetStatusText() 
{
         $this->assertTrue('Started' == $this->issues('issueBug')->getStatusText());
}

还有这个

public function testGetTypeText()
{
    $this->assertTrue('Bug' == $this->issues('issueBug')->getTypeText());
}

现在,如果我们运行测试,我们将得到一个失败的提示,因为我们现在还没有添加这两个公有方法到我们的AR类中:

>>phpunit unit/IssueTest.php 
PHPUnit 3.4.12 by Sebastian Bergmann. 
..EE 
Time: 2 seconds, Memory: 12.25Mb 
There were 2 errors:
 
1) IssueTest::testGetStatusText 
Exception: Unknown method 'issues' for class 'IssueTest'.
 
...
 
2) IssueTest::testGetTypeText 
Exception: Unknown method 'issues' for class 'IssueTest'.
 
...
 
FAILURES! 
Tests: 4, Assertions: 10, Errors: 2.

所以,我们已经得到了我们的失败的测试,让我们来添加必要的代码到 /protected/models/Issue.php文件中,来让测试通过。将下列的公有方法添加到问题(Issue)类中来取得当前问题(Issue)的状态和类型文字:

/**
 * @return string the status text display for the current issue
 */
public function getStatusText() {
    $statusOptions = $this->statusOptions;
    return isset($statusOptions[$this->status_id]) ?
            $statusOptions[$this->status_id] :
            "unknown status ({$this->status_id})";
}
 
/**
 * @return string the type text display for the current issue
 */
public function getTypeText() {
    $typeOptions = $this->typeOptions;
    return isset($typeOptions[$this->type_id]) ?
            $typeOptions[$this->type_id] :
            "unknown type ({$this->type_id})";
}

现在我们再次运行测试:

>>phpunit unit/IssueTest.php 
.... 
Time: 1 second, Memory: 12.25Mb 
OK (4 tests, 12 assertions)

我们已经通过了测试,并且回到了“绿色区”。

添加文字显示到表单

现在我们已经有了两个新的公有方法,可以返回有效的状态和类型的文字以帮助我们显示列表,我们需要利用好它们。修改/proteced/views/issue/_view.php文件中的下列行:

修改下列命令:

<?php echo CHtml::encode($data->type_id); ?>

为:

<?php echo CHtml::encode($data->getTypeText()); ?>

并修改下列命令:

<?php echo CHtml::encode($data->status_id); ?>

为:

<?php echo CHtml::encode($data->getStatusText()); ?>

在这些修改之后,我们的问题(Issue)列表页面,http://localhost/trackstar/index.php?r=issue不再显示整数值到我们的类型和状态域中。它显示的内容看起来和下面的截屏比较像:

因为我们使用了相同的视图文件来显示问题(Issue)列表和我们的项目(Project)详情页面,这些变化也在那边体现了出来。

改变问题(Issue)详情视图

我们还需要对问题(Issue)的详情页面做一些修更改。现在,我们打开问题(Issue)的详情页面,它看起来像下面的截屏:

这个页面使用了一个我们还没有修改的视图文件。它显示的还是项目(Project)的ID,我们并不需要,而且类型和状态显示的还是整数值,而不是对应的文字值。打开生成这个页面的视图文件,/protected/views/issue/view.php,我们注意到它使用了Zii扩展挂件,CDetailView,我们以前并没有见过这个。这个和CListView是相似的东西,用于显示一个列表,但是它用于显示一个单一数据模型的详细内容,而不是显示一个多项的列表。这个文件中的相关代码显示了这个挂件的用法:

$this->widget('zii.widgets.CDetailView', array(
    'data'=>$model,
    'attributes'=>array(
        'id',
        'name',
        'description',
        'project_id',
        'type_id',
        'status_id',
        'owner_id',
        'requester_id',
        'create_time',
        'create_user_id',
        'update_time',
        'update_user_id',
    ),
));

这里,我们将CDetailView挂件的数据模型设定为问题(Issue)模型类,然后在生成的细节视图中设定一个被显示的模型属性的列表。一个属性可以被指定是一个字符串,按照如下格式名字:类型:标签,类型和标签是可选的,或者属性本身就是一个数组。这里只指定了属性的名字。

如果我们指定了一个属性为一个数组,我们可以通过指定一个value元素来进一步自定义显示。我们将用这个方法来使得类型和状态域分别使用getTypeText()和getStatusText()模型类方法显示。

让我们使用如下的配置来修改CDetailView:

$this->widget('zii.widgets.CDetailView', array(
    'data' => $model,
    'attributes' => array(
        'id',
        'name',
        'description',
        array(
            'name' => 'type_id',
            'value' => CHtml::encode($model->getTypeText())
        ), array(
            'name' => 'status_id',
            'value' => CHtml::encode($model->getStatusText())
        ),
        'owner_id',
        'requester_id',),
));

这里我们移除了几个属性,并不全部显示所有的。project_id, create_time, update_time, create_user_id和update_user_id。我们将在后面来出来这些字段的显示和填充,现在我们只是将它们从细节中移除。

我们还修改了type_id 和 status_id的声明,使用了数组来设定,这样我们可以使用value元素。我们已经指定使用Issue::getTypeText()和Issue::getStatusText()方法来取得这些属性的值。做了这些修改后,问题(Issue)详情页面看起来像这个样子。

好,离我们想要的东西更进一步了,但是我们还需要做几个修改。

让所有者和请求者的名字显示出来

事情看起来更好了,但是我们还是看到了整数标识符显示在所有者和请求者的位置上,而不是显示真是的用户姓名。我们将使用处理类型和状态时候相似的方法。我们会添加两个公有方法在问题(Issue)模型类中,来返回这两个属性的名字。

使用关系型AR

由于问题(Issue)和用户由不同的数据库表表示,并使用一个外键来关联,我们可以直接从视图文件中的$model访问所有者和请求者。利用Yii框架中AR模型的强大特性,可以很容易的在用户模型类实例中显示用户名属性。

我们已经提过了,模型的Issue::relations()方法是定义关系的地方。如果我们看一下这个方法,我们会看到:

/**
 * @return array relational rules.
 */
public function relations() {
    // NOTE: you may need to adjust the relation name and the related
    // class name for the relations automatically generated below.
    return array(
        'requester' => array(self::BELONGS_TO, 'User', 'requester_id'),
        'owner' => array(self::BELONGS_TO, 'User', 'owner_id'),
        'project' => array(self::BELONGS_TO, 'Project', 'project_id'),
    );
}

高亮的代码是与我们需求高度相关的。所有者和请求者属性都被定义为了用户模型类的相关。这些定义指定了用户模型类实例中的属性的值。owner_id 和requester_id确定了他们对应的用户类实例的主键。所以,我们可以像访问其他问题(Issue)模型的属性一样访问他们。

所以,要显示用户类实例的所有者和请求者,我们再一次修改我们的CDetailView配置为:

$this->widget('zii.widgets.CDetailView', array(
    'data' => $model,
    'attributes' => array(
        'id',
        'name',
        'description',
        array(
            'name' => 'type_id',
            'value' => CHtml::encode($model->getTypeText())
        ), array(
            'name' => 'status_id',
            'value' => CHtml::encode($model->getStatusText())
        ),
        array(
            'name' => 'owner_id',
            'value' => CHtml::encode($model->owner->username)
        ),
        array(
            'name' => 'requester_id',
            'value' => CHtml::encode($model->requester->username)
        ),
    ),
));

做了上述修改后,我们的问题(Issue)详情页面看起来好看了不少。请看下面的截图:

做最后的一些导航修改

我们离完成这个迭代之初计划的功能已经非常接近了。唯一剩下的就是要清理一下导航。你可能已经注意到了,仍旧有一些选项可以使得用户导航到全部问题(Issue)的列表,或者在项目(Project)的范围外,创建一个新的问题(Issue)。对于TrackStar应用的目的来说,与问题(Issue)相关的所有操作,应该在一个特定项目(Project)的上下文中。早先,我们强制在创建一个新的问题(Issue)的时候,指定项目(Project)上下文(很好的开始),但是我们还是需要一些修改。

我们注意的一个东西是,应用仍然允许用户导航到所有问题(Issue)的列表页面,没有项目(Project)的区分。例如,在一个问题(Issue)详情页面,如http://localhost/trackstar/index.php?r=issue/view&id=1,我们看到右侧菜单导航有两个链接,列出问题(Issue)和管理问题(Issue),对应着链接http://localhost/trackstar/index.php?r=issue/indexhttp://localhost/trackstar/index.php?r=issue/admin(记得要访问管理页面,你必须登录admin/admin)。这些页面仍然显示所有项目(Project)的所有的问题(Issue)。所以,我们必须限制这些列表到特定的项目(Project)。

由于这些链接原来是问题(Issue)详情页面的,那个特定的问题(Issue)已经关联了一个项目(Project),我们可以首先修更改这些链接,来传递一个特定的项目(Project)ID,然后把那个项目(Project)ID传给IssueController::actionIndex()和IssueController::actionAdmin()方法。

首先,修改链接。打开文件 /protected/views/issue/view.php,找到文件顶部菜单项数组。修改菜单的配置为如下:

$this->menu = array(
    array('label' => 'List Issue', 'url' => array('index', 'pid' => $model->project->id)),
    array('label' => 'Create Issue', 'url' => array('create', 'pid' => $model->project->id)),
    array('label' => 'Update Issue', 'url' => array('update', 'id' => $model->id)),
    array('label' => 'Delete Issue', 'url' => '#', 'linkOptions' => array('submit' => array('delete', 'id' => $model->id), 'confirm' => 'Are you sure you want to delete this item?')),
    array('label' => 'Manage Issue', 'url' => array('admin', 'pid' => $model->project->id))
);

这些修改被高亮了。我们已经添加了一个新的查询参数到新的创建问题(Issue)链接,而且也添加到了问题(Issue)列表链接和问题(Issue)管理链接。我们已经知道,我们必须为创建问题(Issue)链接做此项修更改,因为我们之前已经实现了一个过滤器来强制指定一个项目(Project)上下文。我们将来不会进一步修改这个链接了,我们将修更改他们对应的动作方法来利用这个新的查询字符串变量。

由于我们已经配置了一个过滤器来利用查询字符串装载关联的项目(Project),让我们利用这个。我们将要修改过滤器配置,使得我们的过滤器在IssueController::actionIndex()和IssueController::actionAdmin()方法调用之前,被调用。修改过滤器方法如下:

/**
 * @return array action filters
 */
public function filters() {
    return array(
        'accessControl',
        // perform access control for CRUD operations
        'projectContext + create index admin',
        //perform a check to ensure valid project context
    );
}

在这里,关联的项目(Project)将会被装载。让我们在IssueController::actionIndex()方法中来使用它。修改方法为:

public function actionIndex() {
    $dataProvider = new CActiveDataProvider('Issue',array(
        'criteria' => array(
        'condition' => 'project_id=:projectId',
        'params' => array(
            ':projectId' => $this->_project->id),
        ),
    ));
    
    $this->render('index', array(
        'dataProvider' => $dataProvider,
    ));
}

这里,我们先前已经做过了,我们只是简单地添加一个条件到模型数据提供者的创建过程中,只取回与项目(Project)相关联的问题(Issue)。这将会限制问题(Issue)的列表中只出现该项目(Project)下的问题(Issue)。

我们对于管理列表页面,也需要做相同的修改。然而,视图文件/protected/views/issue/admin.php中使用了模型类Issue::search()方法来提供问题(Issue)的列表。所以,我们实际上需要修改两处地方在这个列表中强制项目(Project)上下文。

首先,我们需要修改IssueController::actionAdmin()方法来设定发送给视图的模型实例的project_id属性。下列高亮代码是修改的地方:

public function actionAdmin() {
     $model = new Issue('search');
     if (isset($_GET['Issue']))
         $model->attributes = $_GET['Issue'];
 
     $model->project_id = $this->_project->id; //&lt;--这个
 
     $this->render('admin', array(
         'model' => $model,
     ));
}

然后,我们需要将我们的标准添加到Issue::search()模型类方法。下列高亮代码标识出了我们需要进行的修改:

public function search() {
 // Warning: Please modify the following code to remove attributes that
 // should not be searched.
     $criteria = new CDbCriteria;
     $criteria->compare('id', $this->id);
     $criteria->compare('name', $this->name, true);
     $criteria->compare('description', $this->description, true);
     $criteria->compare('type_id', $this->type_id);
     $criteria->compare('status_id', $this->status_id);
     $criteria->compare('owner_id', $this->owner_id);
     $criteria->compare('requester_id', $this->requester_id);
     $criteria->compare('create_time', $this->create_time, true);
     $criteria->compare('create_user_id', $this->create_user_id);
     $criteria->compare('update_time', $this->update_time, true);
     $criteria->compare('update_user_id', $this->update_user_id);
     $criteria->condition = 'project_id=:projectID';    //-->这行
     $criteria->params = array(    //-->这行
         ':projectID' => $this->project_id    //-->这行
     );   //-->这行
     return new CActiveDataProvider(get_class($this), array(
         'criteria' => $criteria,
     ));
 }

有了这些修改,管理页面的问题(Issue)列表现在被限制到了特定项目(Project)的范围内。

在/protected/views/issues/目录中的视图文件里,有多处链接需要包含pid请求字符串,以使得他们能够正常工作。我们将它们作为练习留给读者,请按照上面三个例子中的方法做适当的修改。随着我们应用的开发进程,我们假设所有的创建新问题(Issue)或者显示问题(Issue)列表的链接已经被正确处理并包含了pid请求字符串参数。

小结

我们在这个迭代中,讨论了相当多的主题。基于议题,项目(Project)和用户在我们应用中的关系,我们议题管理功能的实现比上个迭代中,项目(Project)实体管理的实现要复杂很多。幸运的是,Yii在许多时候,都可以将我们从编写代码来满足这些复杂性的痛苦中解放出来。

具体来说,我们讨论了如下内容:

  • 使用Gii代码生成工具创建活动记录模型,并且初始实现议题实体之上的CRUD操作
  • 设计和创建带有显式关系的数据库表
  • 使用带有关系的活动记录
  • 添加下拉菜单型的表单元素
  • 控制器过滤器

我们已经让我们的基础应用进步了许多,并且做完这一切,我们并没有写很多的代码。Yii框架已经做了大多数繁重的工作。现在我们已经有一个可以工作的应用,我们可以用其管理项目(Project),并在项目(Project)中管理议题。这是我们的应用想要实现的核心功能。我们应该为取得的这些成绩感到自豪。

然而,在这个应用能够被用于生产环境之前,我们还有很长的路要走。缺失的最主要的一块东西,就是用户管理的功能。这将在接下来的两个迭代中涉及到。

评论 X

      友荐云推荐
      Copyright 2011-2014. YiiBook.com