首页 前端知识 HTML5 WebSocket、PHP 和 jQuery 实时 Web 应用(三)

HTML5 WebSocket、PHP 和 jQuery 实时 Web 应用(三)

2024-08-14 00:08:20 前端知识 前端哥 127 659 我要收藏

原文:Realtime Web Apps

协议:CC BY-NC-SA 4.0

九、构建后端:第二部分

在上一章中,您构建了一个可工作的 MVC 框架,并将家庭控制器和视图放在一起。在本章中,您将通过构建QuestionRoom组件来完成应用的后端。本章使用了Model类,以及您在前一章中构建的表单提交处理。

构建问题

Room控制器之前构建Question控制器似乎有些落后,但是因为Room控制器需要来自Question控制器的几个标记片段来输出它的视图,所以让我们从这里开始,然后处理Room控制器。

构建问题控制器

首先,在system/controllers/中创建一个名为class.question.inc.php的新文件,并添加以下代码:

<?php

/**
 * Processes output for the Question view
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Question extends Controller
{

    public $room_id,
           $is_presenter = FALSE;

    /**
     * Initializes the class
     *
     * @param $options  array   Options for the controller
     * @return          void
     */
    public function __construct( $options )
    {
        parent::__construct($options);

        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }
    }

    /**
     * Generates the title of the page
     *
     * @return string   The title of the page
     */
    public function get_title(  )
    {
        // Questions can't be called directly, so this is unused
        return NULL;
    }

    /**
     * Loads and outputs the view's markup
     *
     * @return string   The HTML markup to display the view
     */
    public function output_view(  )
    {
        $questions = $this->get_questions();

        $output = NULL;
        foreach ($questions as $question) {

            /*
             * Questions have their own view type, so this section initializes
             * and sets up variables for the question view
             */
            $view = new View('question');
            $view->question     = $question->question;
            $view->room_id      = $this->room_id;
            $view->question_id  = $question->question_id;
            $view->vote_count   = $question->vote_count;

            if ($question->is_answered==1) {
                $view->answered_class = 'answered';
            } else {
                $view->answered_class = NULL;
            }

            // TODO: Check if the user has already voted up the question
            $view->voted_class = NULL;

            // TODO: Load the vote up form for attendees, but not presenters
            $view->vote_link = '';

            // TODO: Load the answer form for presenters, but not attendees
            $view->answer_link = '';

            // Returns the output of render() instead of printing it
            $output .= $view->render(FALSE);
        }

        return $output;
    }

}

这个方法还没有完成,但是构建模块已经就位,可以开始查看视图将如何形成。

构造函数触发主Controller构造函数,然后检查有效的房间 id,如果在 URI 中没有传递,则抛出一个错误。

因为问题永远不会单独显示——意思是在房间的上下文之外——get_title()方法只返回NULL。记住它不需要声明,因为它是抽象父类的一部分。

output_view()方法使用get_questions()方法加载房间的所有问题,稍后您将对其进行定义。然后它遍历每个问题,加载问题视图并用单个问题的数据填充它。一些变量需要更新;它们中的每一个都被标上了TODO的注释,所以以后回来写这些内容时,很容易就能发现它们。

添加问题视图

问题应用的视图看起来不太像;这只是你在第七章写的 HTML 的一个片段。然而,它有很多变数。

创建一个名为question.inc.php的新文件,并将其存储在system/views/中。在里面,添加以下内容:

<li id="question-<?php echo $question_id; ?>"
    data-count="<?php echo $vote_count; ?>"
    class="<?php echo $voted_class, ' ', $answered_class; ?>">
    <?php echo $answer_link; ?>
    <p>
        <?php echo $question; ?>
    </p>
    <?php echo $vote_link; ?>
</li><!--/#question-<?php echo $question_id; ?>-->

这个标记显示了在output_view()中设置的变量,如前所示。目前,这个视图看起来不太像,因为$voted_class$voted_link$answer_link都是NULL或空的。

完成视图

声明的几个变量是NULL或空的,因为检索所需数据的方法还不存在。根据注释中的TODO s,循环还需要:

  • 检查用户是否已经对问题投了赞成票
  • 为与会者而不是演示者加载“向上投票”表单
  • 为演示者而不是与会者加载答案表单

检查用户是否已经对某个问题投了赞成票

为了确定用户是否对某个问题投了赞成票,我们将使用一个简单的 cookie。当用户为一个问题投票时,会存储一个名为voted_for_n(其中 n 是问题的 ID)的 cookie。这将允许应用防止一个用户提交多个投票。

要检查 cookie,将以下粗体代码添加到output_view():

public function output_view(  )
{
    $questions = $this->get_questions();

    $output = NULL;
    foreach ($questions as $question) {

        /*
         * Questions have their own view type, so this section initializes
         * and sets up variables for the question view
         */
        $view = new View('question');
        $view->question     = $question->question;
        $view->room_id      = $this->room_id;
        $view->question_id  = $question->question_id;
        $view->vote_count   = $question->vote_count;

        if ($question->is_answered==1) {
            $view->answered_class = 'answered';
        } else {
            $view->answered_class = NULL;
        }

        // Checks if the user has already voted up this question
        $cookie = 'voted_for_' . $question->question_id;
        if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) {
            $view->voted_class = 'voted';
        } else {
            $view->voted_class = NULL;
        }

        // TODO: Load the vote up form for attendees, but not presenters
        $view->vote_link = '';

        // TODO: Load the answer form for presenters, but not attendees
        $view->answer_link = '';

        // Returns the output of render() instead of printing it
        $output .= $view->render(FALSE);
    }

    return $output;
}

这段代码检查一个 cookie,这个 cookie 表示这个问题已经被投票了,如果是的话,设置一个类名来改变样式。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 警告在生产环境中,应用需要为关闭 cookie 的用户添加故障保护,或者在未启用 cookie 的情况下禁止用户使用应用。完全锁定表单提交超出了本书的范围,但是如果你想了解更多,网上有很多很好的资源。

加载向上投票表单

为了向与会者而不是演示者显示向上投票表单,需要创建一个新方法,该方法将有条件地生成一个视图,该视图可用于问题视图中的输出。

system/controllers/class.question.inc.php中,添加以下粗体代码:

public function output_view(  )
{
    $questions = $this->get_questions();

    $output = NULL;
    foreach ($questions as $question) {

        /*
         * Questions have their own view type, so this section initializes
         * and sets up variables for the question view
         */
        $view = new View('question');
        $view->question     = $question->question;
        $view->room_id      = $this->room_id;
        $view->question_id  = $question->question_id;
        $view->vote_count   = $question->vote_count;

        if ($question->is_answered==1) {
            $view->answered_class = 'answered';
        } else {
            $view->answered_class = NULL;
        }

        // Checks if the user has already voted up this question
        $cookie = 'voted_for_' . $question->question_id;
        if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) {
            $view->voted_class = 'voted';
        } else {
            $view->voted_class = NULL;
        }

        $view->vote_link = $this->output_vote_form(
            $this->room_id,
            $question->question_id,
            $question->is_answered
        );

            // TODO: Load the answer form for presenters, but not attendees
            $view->answer_link = '';

        // Returns the output of render() instead of printing it
        $output .= $view->render(FALSE);
    }

    return $output;
}

/**
 * Generates the voting form for attendees
 *
 * @param $question_id  int     The ID of the question
 * @param $answered     int     1 if answered, 0 if unanswered
 * @return              mixed   Markup if attendee, NULL if presenter
 */
protected function output_vote_form( $room_id, $question_id, $answered )
{
    $view = new View('question-vote');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/vote';
    $view->nonce       = $this->generate_nonce();
    $view->disabled    = $answered==1 ? 'disabled' : NULL;

    return $view->render(FALSE);
}

output_vote_form()方法接受三个参数:当前房间的 ID、当前问题的 ID 以及该问题是否已被回答。

然后它加载一个新的视图—question-vote—并为输出设置变量。房间 ID、问题 ID、表单动作和 nonce 以及$disabled都被存储,如果问题已经被标记为已回答,则防止表单被提交。

这个方法返回它的输出,存储在$vote_link变量中,在这里它成为问题视图的一部分。

添加向上投票表单视图

投票表单的视图非常简单。在system/views/中创建一个名为question-vote.inc.php的新文件,并添加以下标记:

<form method="post" class="vote"
      action="<?php echo $form_action; ?>">
    <input value="I also have this question."
           type="submit"<?php echo $disabled; ?>/>
    <input type="hidden" name="question_id"
           value="<?php echo $question_id; ?>" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form>

该标记使用在output_vote_form()中设置的变量来动态生成按钮,该按钮允许与会者提交对某个问题的投票。

加载答案表单

与 vote up 表单非常相似,您现在需要添加一个方法来加载答案表单,这允许演示者标记一个已回答的问题。将以下代码添加到Question类:

public function output_view(  )
{
    $questions = $this->get_questions();

    $output = NULL;
    foreach ($questions as $question) {

        /*
         * Questions have their own view type, so this section initializes
         * and sets up variables for the question view
         */
        $view = new View('question');
        $view->question     = $question->question;
        $view->room_id      = $this->room_id;
        $view->question_id  = $question->question_id;
        $view->vote_count   = $question->vote_count;

        if ($question->is_answered==1) {
            $view->answered_class = 'answered';
        } else {
            $view->answered_class = NULL;
        }

        // Checks if the user has already voted up this question
        $cookie = 'voted_for_' . $question->question_id;
        if (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1) {
            $view->voted_class = 'voted';
        } else {
            $view->voted_class = NULL;
        }

        $view->vote_link = $this->output_vote_form(
            $this->room_id,
            $question->question_id,
            $question->is_answered
        );

        $view->answer_link = $this->output_answer_form(
            $this->room_id,
            $question->question_id
        );

        // Returns the output of render() instead of printing it
        $output .= $view->render(FALSE);
    }

    return $output;
}

protected function output_vote_form( $room_id, $question_id, $answered )
{
    $view = new View('question-vote');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/vote';
    $view->nonce       = $this->generate_nonce();
    $view->disabled    = $answered==1 ? 'disabled' : NULL;

    return $view->render(FALSE);
}

/**
 * Generates the answering form for presenter
 *
 * @param $room_id      int     The ID of the room
 * @param $question_id  int     The ID of the question
 * @return              mixed   Markup if presenter, NULL if attendee
 */
protected function output_answer_form( $room_id, $question_id )
{
    $view = new View('question-answer');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/answer';
    $view->nonce       = $this->generate_nonce();

    return $view->render(FALSE);
}

该方法遵循与 output_vote_form() 相同的模式:它使用 question-answer 创建一个新视图,并设置用于生成标记的变量。

添加答案表单视图

创建一个名为question-answer.inc.php的新文件,并将其保存在system/views/中,其中包含以下标记:

<form method="post" class="answer"
      action="<?php echo $form_action; ?>">
    <input type="submit" value="Answer this question." />
    <input type="hidden" name="question_id"
           value="<?php echo $question_id; ?>" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form>

该标记使用在output_answer_form()中设置的变量来生成标记,供演示者将问题标记为已回答。

为投票和回答的问题添加样式

因为已经被投票或回答的问题不再是交互式的,按钮应该不再显示为可点击的。打开assets/styles/main.css并在媒体查询上方插入以下 CSS:

/* Voted and answered styles
 ----------------------------------------------------------------------------*/

#questions .voted .vote input {
    background-position: left bottom;
    width: 78px;
    cursor: initial;
}

#questions .answered .answer input:active,
#questions .answered .answer input:hover,
#questions .answered .answer input:focus {
    background-position: right top;
    cursor: initial;
}

#questions .answered .vote input:active,
#questions .answered .vote input:hover,
#questions .answered .vote input:focus {
    background-position: left bottom;
    cursor: initial;
}

/* Transition effects
 ----------------------------------------------------------------------------*/

#questions li,#questions .vote {
    -webkit-transition: opacity 1s ease-in-out;
       -moz-transition: opacity 1s ease-in-out;
        -ms-transition: opacity 1s ease-in-out;
         -o-transition: opacity 1s ease-in-out;
            transition: opacity 1s ease-in-out;
}

#questions.closed,#questions li.answered { opacity: .4; }

#questions.closed .vote,#questions .answered .vote { opacity: .2; }

这些样式防止按钮在悬停时高亮显示,并防止鼠标光标变成指针,这是元素可点击的标准指示。

当投票或回答问题时,过渡效果可以创建动画淡入淡出效果。在这种情况下,为了触发 CSS 转换,元素需要添加一个类,所以请记住,在下一章实现 realtime 和 jQuery 效果之前,转换是不可见的。

加载房间的所有问题

output_view()方法的最后一部分是目前不存在的方法get_questions()。实际的数据库查询将在本章稍后添加到问题模型中,但是现在,让我们在控制器中声明方法。

将以下粗体代码添加到Question类中:

public function output_ask_form( $is_active, $email )
{
    if ($is_active) {
        $view = new View('ask-form');
        $view->room_id     = $this->room_id;
        $view->form_action = APP_URI . 'question/ask';
        $view->nonce       = $this->generate_nonce();

        return $view->render(FALSE);
    } else {
        $view = new View('room-closed');
        $view->email = $email;

        return $view->render(FALSE);
    }
}

/**
 * Loads questions for the room
 *
 * @return array   The question data as an array of objects
 */
protected function get_questions(  )
{
    return $this->model->get_room_questions($this->room_id);
}

这个方法只是从 Question_Model 类中调用一个方法,这个类还没有被定义。一旦建立了模型,这个方法将返回给定房间 ID 的所有问题。

添加提问表单

除了投票和回答表单之外,还有一个表单需要添加到Question类中:用于提出新问题的表单。

添加提问方法

Question类中,使用下面的粗体代码添加新方法:

protected function output_answer_form( $room_id, $question_id )
{
    $view = new View('question-answer');
    $view->room_id     = $room_id;
    $view->question_id = $question_id;
    $view->form_action = APP_URI . 'question/answer';
    $view->nonce       = $this->generate_nonce();

    return $view->render(FALSE);
}

/**
 * Generates the form to ask a new question
 *
 * @param  $is_active   bool    Whether or not the room is active
 * @param  $email       string  The email address of the presenter
 * @return              string  The markup to display the form
 */
public function output_ask_form( $is_active, $email )
{
    if ($is_active) {
        $view = new View('ask-form');
        $view->room_id     = $this->room_id;
        $view->form_action = APP_URI . 'question/ask';
        $view->nonce       = $this->generate_nonce();

        return $view->render(FALSE);
    } else {
        $view = new View('room-closed');
        $view->email = $email;

        return $view->render(FALSE);
    }
}

这个方法与其他两个 form 方法非常相似,但是有一个重要的区别:根据房间是否活动,这个方法可以返回两个视图。

ask-form视图输出允许与会者提出新问题的表单。

room-closed视图使用演示者的电子邮件地址,允许任何进入封闭房间的人跟进任何其他问题。

添加提问视图

system/views/中创建一个名为ask-form.inc.php的新文件,并插入以下标记:

<form id="ask-a-question" method="post"
      action="<?php echo $form_action; ?>">
    <label>
        If you have a question and you don't see it below, ask it here.
        <input type="text" name="new-question" tabindex="1" />
    </label>
    <input type="submit" value="Ask" tabindex="2" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form><!--/#ask-a-question-->

这种标记创建了询问新问题的形式。

添加房间封闭视图

system/views/中,用以下标记添加一个名为room-closed.inc.php的新文件:

<h3>This session has ended.</h3>
<p>
    If you have a question that wasn't answered, please
    <a href="mailto:<?php echo $email; ?>">email the presenter</a>.
</p>

这种标记让与会者知道房间关闭了,但提供了一个电子邮件地址,以便与演示者联系,这样他就不会完全不走运。

构建问题模型

为了存储关于问题及其投票的数据,您现在需要创建一个模型类,它将包含所有与问题相关的数据库操作方法。

首先在system/models/中创建一个名为class.question_model.inc.php的新文件,其类定义如下:

<?php

/**
 * Creates database interaction methods for questions
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Question_Model extends Model
{

}

加载房间的所有问题

为了加载一个房间的所有问题,房间 ID 被传递给get_room_questions()方法。结果作为对象加载,然后传递回控制器进行处理。

为了按逻辑顺序检索问题(即,最高票数、未回答的问题显示在列表顶部),使用LEFT JOIN来利用来自question_votes的投票计数进行排序。

将以下粗体代码添加到Question_Model:

class Question_Model extends Model
{

    /**
     * Loads all questions for a given room
     *
     * @param   $room_id    int     The ID of the room
     * @return              array   The questions attached to the room
     */
    public function get_room_questions( $room_id )
    {
        $sql = "SELECT
                    id AS question_id,
                    room_id,
                    question,
                    is_answered,
                    vote_count
                FROM questions
                    LEFT JOIN question_votes
                        ON( questions.id = question_votes.question_id )
                WHERE room_id = :room_id
                ORDER BY is_answered, vote_count DESC";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $questions = $stmt->fetchAll(PDO::FETCH_OBJ);
        $stmt->closeCursor();

        return $questions;
    }

}

保存新问题

为了将新问题保存到数据库,房间 ID 和新问题文本(作为字符串)都被传递给create_question()方法。第一个查询将问题插入到questions表中,然后将新保存的问题的 ID 存储在$question_id中。

接下来,使用新创建的问题 ID 将问题的第一票(因为提问的用户算作第一票)添加到question_votes表中。

通过将以下粗体代码添加到Question_Model来实现该方法:

public function get_room_questions( $room_id )
{
    $sql = "SELECT
                id AS question_id,
                room_id,
                question,
                is_answered,
                vote_count
            FROM questions
                LEFT JOIN question_votes
                    ON( questions.id = question_votes.question_id )
            WHERE room_id = :room_id
            ORDER BY is_answered, vote_count DESC";
    $stmt = self::$db->prepare($sql);
    $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
    $stmt->execute();
    $questions = $stmt->fetchAll(PDO::FETCH_OBJ);
    $stmt->closeCursor();

    return $questions;
}

/**
 * Stores a new question with all the proper associations
 *
 * @param   $room_id    int     The ID of the room
 * @param   $question   string  The question text
 * @return              array   The IDs of the room and the question
 */
public function create_question( $room_id, $question )
{
    // Stores the new question in the database
    $sql = "INSERT INTO questions (room_id, question)
            VALUES (:room_id, :question)";
    $stmt = self::$db->prepare($sql);
    $stmt->bindParam(':room_id', $room_id);
    $stmt->bindParam(':question', $question);
    $stmt->execute();
    $stmt->closeCursor();

    // Stores the ID of the new question
    $question_id = self::$db->lastInsertId();

    /*
     * Because creating a question counts as its first vote, this adds a
     * vote for the question to the database
     */
    $sql = "INSERT INTO question_votes
            VALUES (:question_id, 1)";
    $stmt = self::$db->prepare($sql);
    $stmt->bindParam(":question_id", $question_id, PDO::PARAM_INT);
    $stmt->execute();
    $stmt->closeCursor();

    return array(
        'room_id'     => $room_id,
        'question_id' => $question_id,
    );
}

向问题添加投票

更新投票计数很简单:对于给定的问题 ID,vote_question()方法将投票计数增加1。将这个方法(粗体)添加到Question_Model类:

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

    /**
     * Increases the vote count of a given question
     *
     * @param   $room_id        int     The ID of the room
     * @param   $question_id    int     The ID of the question
     * @return                  array   The IDs of the room and the question
     */
    public function vote_question( $room_id, $question_id )
    {
        // Increments the vote count for the question
        $sql = "UPDATE question_votes
                SET vote_count = vote_count+1
                WHERE question_id = :question_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':question_id', $question_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

}

数据库查询通过在当前值vote_count = vote_count+1上加 1,将具有给定 ID 的问题的投票计数增加 1,然后返回房间和问题 ID。

将问题标记为已回答

最后,为了将问题标记为已回答,具有给定 ID 的问题的is_answered列被更新为1。将以下粗体代码添加到Question_Model :

        $stmt->closeCursor();

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

    /**
     * Marks a given question as answered
     *
     * @param   $room_id        int     The ID of the room
     * @param   $question_id    int     The ID of the question
     * @return                  array   The IDs of the room and question
     */
    public function answer_question( $room_id, $question_id )
    {
        $sql = "UPDATE questions
                SET is_answered = 1
                WHERE id = :question_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':question_id', $question_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id'     => $room_id,
            'question_id' => $question_id,
        );
    }

}

向控制器添加表单处理程序和数据访问方法

应用问题部分的最后一点是将动作数组、模型和动作处理程序类添加到Question控制器中。

首先用模型声明和动作数组更新构造函数。将以下内容添加到system/controllers/class.question.inc.php :

public function __construct( $options )
{
    parent::__construct($options);

    $this->model = new Question_Model;

    // Checks for a form submission
    $this->actions = array(
        'ask'    => 'create_question',
        'vote'   => 'vote_question',
        'answer' => 'answer_question',
    );

    if (array_key_exists($options[0], $this->actions)) {
        $this->handle_form_submission($options[0]);
        exit;
    } else {
        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }
    }
}

这将加载用于数据访问的Question_Model类,然后声明三种可能的表单动作及其所需的动作处理程序方法。

保存新问题

保存新问题的操作处理程序相当复杂,因为它是在下一章构建的——在下一章中,我们将开始添加实时功能——考虑在内。因此,它不仅存储新的问题,还生成新的问题视图以供返回,这样以后就不需要在客户端呈现标记了。它还为用户添加了一个 cookie,表明他们已经为这个问题投了票。

将下面的粗体代码添加到Question类中:

    protected function get_questions(  )
    {
        return $this->model->get_room_questions($this->room_id);
    }

    /**
     * Adds a new question to the database
     *
     * @return array    Information about the updated question
     */
    protected function create_question(  )
    {
        $room_id  = $this->sanitize($_POST['room_id']);
        $question = $this->sanitize($_POST['new-question']);

        $output = $this->model->create_question($room_id, $question);

        // Make sure valid output was returned
        if (is_array($output) && isset($output['question_id'])) {
            $room_id     = $output['room_id'];
            $question_id = $output['question_id'];

            // Generates markup for the question (for realtime addition)
            $view = new View('question');
            $view->question       = $question;
            $view->room_id        = $room_id;
            $view->question_id    = $question_id;
            $view->vote_count     = 1;
            $view->answered_class = NULL;
            $view->voted_class    = NULL;

            $view->vote_link = $this->output_vote_form(
                $room_id,
                $question_id,
                FALSE
            );

            $view->answer_link = $this->output_answer_form(
                $room_id,
                $question_id
            );

            $output['markup'] = $view->render(FALSE);
        } else {
            throw new Exception('Error creating the room.');
        }

        // Stores a cookie so the attendee can only vote once
        setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/');

        return $output;
    }

}

该方法首先净化提交的数据,使用模型的create_question()方法将其存储在数据库中,并检查有效的返回值。然后,它创建一个新的question视图,并存储所有变量,为一个新问题生成标记。存储了一个 cookie,表明与会者发布了问题的第一个向上投票;然后返回标记。

向问题添加投票

向一个问题添加新的投票会执行vote_question()方法,将新的投票存储在数据库中,然后为投票者设置一个 cookie,以防止对同一个问题进行多次投票。将以下粗体代码添加到Question控制器中:

        // Stores a cookie so the attendee can only vote once
        setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/');

        return $output;
    }

    /**
     * Increments the vote count for a given question
     *
     * @return array    Information about the updated question
     */
    protected function vote_question(  )
    {
        $room_id     = $this->sanitize($_POST['room_id']);
        $question_id = $this->sanitize($_POST['question_id']);

        // Makes sure the attendee hasn't already voted for this question
        $cookie_id = 'voted_for_' . $question_id;
        if (!isset($_COOKIE[$cookie_id]) || $_COOKIE[$cookie_id]!=1) {
            $output = $this->model->vote_question($room_id, $question_id);

            // Sets a cookie to make it harder to post multiple votes
            setcookie($cookie_id, 1, time() + 2592000, '/');
        } else {
            $output = array('room_id'=>$room_id);
        }

        return $output;
    }

}

将问题标记为已回答

为了将问题标记为已回答,提交表单的用户必须是演示者。这和投票一样,是基于 cookie 的。该方法在执行answer_question()方法之前检查演示者的 cookie。

将以下粗体显示的代码添加到Question控制器中:

        // Stores a cookie so the attendee can only vote once
        setcookie('voted_for_' . $question_id, 1, time() + 2592000, '/');

        return $output;
    }

    /**
     * Marks a given question as answered
     *
     * @return array    Information about the updated question
     */
    protected function answer_question(  )
    {
        $room_id     = $this->sanitize($_POST['room_id']);
        $question_id = $this->sanitize($_POST['question_id']);

        // Makes sure the person answering the question is the presenter
        $cookie_id = 'presenter_room_' . $room_id;
        if (isset($_COOKIE[$cookie_id]) && $_COOKIE[$cookie_id]==1) {
            return $this->model->answer_question($room_id, $question_id);
        }

        return array('room_id'=>$room_id);
    }

}

该方法清理提交的表单值,然后检查演示者的 cookie,以验证当前用户是否有权将问题标记为已回答。如果 cookie 有效,模型的answer_question()方法被触发,其返回的数据被传递;无效或缺失的 cookie 只会将用户返回到房间,而不会通过返回房间 id 进行任何处理。

建造房间

这个应用的最后一部分是为房间添加控制器、模型和视图。这与问题的功能非常相似,除了它实际上加载了Question控制器来加载那些视图并利用它的方法。

添加房间控制器

第一步是创建Room控制器。在system/controllers/中,添加一个名为class.room.inc.php的新文件,并从以下代码开始:

<?php

/**
 * Processes output for the Room view
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Room extends Controller
{

    public $room_id,
           $is_presenter,
           $is_active;

    /**
     * Initializes the view
     *
     * @param $options array    Options for the view
     * @return void
     */
    public function __construct( $options )
    {
        parent::__construct($options);

        $this->model = new Room_Model;

        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }

        $this->room         = $this->model->get_room_data($this->room_id);
        $this->is_presenter = $this->is_presenter();
        $this->is_active    = (boolean) $this->room->is_active;
    }

    /**
     * Generates the title of the page
     *
     * @return string   The title of the page
     */
    public function get_title(  )
    {
        return $this->room->room_name . ' by ' . $this->room->presenter_name;
    }

    /**
     * Loads and outputs the view's markup
     *
     * @return void
     */
    public function output_view(  )
    {
        $view = new View('room');
        $view->room_id   = $this->room->room_id;
        $view->room_name = $this->room->room_name;
        $view->presenter = $this->room->presenter_name;
        $view->email     = $this->room->email;

        if (!$this->is_presenter) {
            $view->ask_form = $this->output_ask_form();
            $view->questions_class = NULL;
        } else {
            $view->ask_form = NULL;
            $view->questions_class = 'presenter';
        }

        if (!$this->is_active) {
            $view->questions_class = 'closed';
        }

        $view->controls  = $this->output_presenter_controls();
        $view->questions = $this->output_questions();

        $view->render();
    }

}

除了标准的构造函数——调用父构造函数,设置模型,并确保提供了有效的选项—Room构造函数还设置了一些特定于房间的属性。

$room属性将保存房间的基本信息,这些信息作为对象从get_room_data()方法返回。该模型将在本章后面的内容中进行设置以检索这些数据。

为了处理演示者和与会者在房间标记上的差异,$is_presenter保存了一个由is_presenter()方法确定的布尔值,稍后将编写这个方法。

最后,$is_active属性作为房间活动状态的快捷方式。因为它在数据库中存储为10,所以它被转换为布尔值,以允许在控制器的方法中进行严格的布尔比较。

get_title()方法使用房间和演示者的名字为房间生成一个有意义的标题。

output_view()中,加载了房间视图,并设置了一些变量,这将在本章的稍后部分进行介绍。

确定用户是否是演示者

因为向演示者显示的标记不同于向与会者显示的标记,所以应用需要一种方法来确定用户是否是当前房间的演示者。这将被存储为一个 cookie。

将以下粗体代码添加到Room控制器中:

        $view->controls  = $this->output_presenter_controls();
        $view->questions = $this->output_questions();

        $view->render();
    }

    /**
     * Determines whether or not the current user is the presenter
     *
     * @return boolean  TRUE if it's the presenter, otherwise FALSE
     */
    protected function is_presenter(  )
    {
        $cookie = 'presenter_room_' . $this->room->room_id;
        return (isset($_COOKIE[$cookie]) && $_COOKIE[$cookie]==1);
    }

}

该方法使用当前房间的 ID 计算出 cookie 名称,然后如果 cookie 设置为等于1,则返回TRUE

添加房间视图

使用第七章的中的房间标记,通过将以下代码添加到位于system/views/room.inc.php : 的新文件中来创建房间视图

<section>

    <header>
        <h2><?php echo $room_name; ?></h2>
        <p>
            Presented by<?php echo $presenter; ?>
            (<a href="mailto:<?php echo $email; ?>">email</a>)
        </p>
        <?php echo $controls; ?>
    </header>

    <?php echo $ask_form; ?>

    <ul id="questions" class="<?php echo $questions_class; ?>">
        <?php echo $questions; ?>
    </ul><!--/#questions-->

</section>

该标记生成向与会者和演示者显示房间及其问题所需的一切。

显示 Ask 表单

ask 表单需要一个新视图,该视图将由Room类中的一个新方法加载。如果您还记得,ask 表单的生成已经由Question控制器处理了,所以新方法将简单地调用Question控制器上的output_ask_form()方法。

将以下粗体代码添加到Room控制器中:

    $view->controls  = $this->output_presenter_controls();
    $view->questions = $this->output_questions();

    $view->render();
}

/**
 * Shows the "ask a question" form or a notice that the room has ended
 *
 * @param $email string The presenter's email address
 * @return string       Markup for the form or notice
 */
protected function output_ask_form(  )
{
    $controller = new Question(array($this->room_id));
    return $controller->output_ask_form(
        $this->is_active,
        $this->room->email
    );
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

显示演讲者控制

对于房间的展示者,我们需要提供房间的直接链接和关闭房间的选项(如果房间已经关闭,则重新打开房间)。为此,向Room控制器添加一个新方法,检查用户是否是演示者;然后检查该房间是否是活动的。

对于非活动房间,重新打开控件将被加载并返回,以便在主房间视图中使用。

活动房间加载标准控件并返回它们。

将以下粗体代码添加到class.room.inc.php:

    return $controller->output_ask_form(
        $this->is_active,
        $this->room->email
    );
}

/**
 * Shows the presenter his controls (or nothing, if not the presenter)
 *
 * @return mixed    Markup for the controls (or NULL)
 */
protected function output_presenter_controls(  )
{
    if ($this->is_presenter) {
        if (!$this->is_active) {
            $view_class  = 'presenter-reopen';
            $form_action = APP_URI . 'room/open';
        } else {
            $view_class  = 'presenter-controls';
            $form_action = APP_URI . 'room/close';
        }

        $view = new View($view_class);
        $view->room_id     = $this->room->room_id;
        $view->room_uri    = APP_URI . 'room/' . $this->room_id;
        $view->form_action = $form_action;
        $view->nonce       = $this->generate_nonce();

        return $view->render(FALSE);
    }

    return NULL;
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

添加不活跃的会议室演示者控制视图

非活动房间的视图是一个简单的表单,输入内容为“打开该房间”。

使用以下标记在system/views/中创建一个名为presenter-reopen.inc.php的文件:

<form id="close-this-room" method="post"
      action="<?php echo $form_action; ?>">
    <input type="submit" value="Open This Room" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form><!--/#close-this-room-->

添加活跃的会议室演示者控制视图

对于活动房间,显示带有房间 URI 的禁用输入,以及显示“关闭此房间”的按钮。

将以下标记添加到system/views/中名为presenter-controls.inc.php的新文件中:

<form id="close-this-room" method="post"
      action="<?php echo $form_action; ?>">
    <label>
        Link to your room.
        <input type="text" name="room-uri"
               value="<?php echo $room_uri; ?>"
               disabled />
    </label>
    <input type="submit" value="Close This Room" />
    <input type="hidden" name="room_id"
           value="<?php echo $room_id; ?>" />
    <input type="hidden" name="nonce"
           value="<?php echo $nonce; ?>" />
</form><!--/#close-this-room-->

展示问题

为了显示房间的问题,output_questions()方法将利用Question控制器遍历现有的问题并返回标记。

在生成标记之前,它设置了$is_presenter属性,以便为用户类型返回正确的标记。

将以下粗体代码添加到Room控制器中:

        return $view->render(FALSE);
    }

    return NULL;
}

/**
 * Loads and formats the questions for this room
 *
 * @return string   The marked up questions
 */
protected function output_questions(  )
{
    $controller = new Question(array($this->room_id));

    // Allows for different output for presenters vs. attendees
    $controller->is_presenter = $this->is_presenter;

    return $controller->output_view();
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

建立房间模型

因为应用需要存储和操作房间数据,所以您需要为房间创建一个模型,名为Room_Model。这将存储在名为class.room_model.inc.php的文件的system/models/子目录中。

创建文件,并以这个基本的类定义开始:

<?php

/**
 * Creates database interaction methods for rooms
 *
 * @author  Jason Lengstorf <jason@lengstorf.com>
 * @author  Phil Leggetter <phil@leggetter.co.uk>
 */
class Room_Model extends Model
{

}

创建房间

模型的第一种方法是将新房间保存到数据库中。

这是一个多步骤的过程;该方法需要做以下工作:

  • rooms表格中创建新房间
  • 检索新房间的 ID
  • 将演示者添加到presenters表中(或者在电子邮件重复的情况下更新演示者的显示名称)
  • presenters表中检索演示者的 ID
  • 将房间 ID 映射到room_owners表中演示者的 ID。

将以下粗体代码添加到房间模型中:

class Room_Model extends Model
{

    /**
     * Saves a new room to the database
     *
     * @param   $presenter  string  The name of the presenter
     * @param   $email      string  The presenter's email address
     * @param   $name       string  The name of the room
     * @return              array   An array of data about the room
     */
    public function create_room( $presenter, $email, $name )
    {
        // Creates a new room
        $sql = 'INSERT INTO rooms (name) VALUES (:name)';
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':name', $name, PDO::PARAM_STR, 255);
        $stmt->execute();
        $stmt->closeCursor();

        // Gets the generated room ID
        $room_id = self::$db->lastInsertId();

        // Creates (or updates) the presenter
        $sql = "INSERT INTO presenters (name, email)
                VALUES (:name, :email)
                ON DUPLICATE KEY UPDATE name=:name";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':name', $presenter, PDO::PARAM_STR, 255);
        $stmt->bindParam(':email', $email, PDO::PARAM_STR, 255);
        $stmt->execute();
        $stmt->closeCursor();

        // Gets the generated presenter ID
        $sql = "SELECT id
                FROM presenters
                WHERE email=:email";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':email', $email, PDO::PARAM_STR, 255);
        $stmt->execute();
        $pres_id = $stmt->fetch(PDO::FETCH_OBJ)->id;
        $stmt->closeCursor();

        // Stores the room:presenter relationship
        $sql = 'INSERT INTO room_owners (room_id, presenter_id)
                VALUES (:room_id, :pres_id)';
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(":room_id", $room_id, PDO::PARAM_INT);
        $stmt->bindParam(":pres_id", $pres_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

}

检查房间是否存在

作为加入房间过程的一部分,Room控制器需要能够验证房间的存在。这个方法简单地选择与给定 ID 匹配的rooms表中的COUNT()个房间;计数1表示房间存在,0表示不存在。

将以下粗体代码添加到Room_Model:

        // Stores the room:presenter relationship
        $sql = 'INSERT INTO room_owners (room_id, presenter_id)
                VALUES (:room_id, :pres_id)';
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(":room_id", $room_id, PDO::PARAM_INT);
        $stmt->bindParam(":pres_id", $pres_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

    /**
     * Checks if a given room exists
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              bool    Whether or not the room exists
     */
    public function room_exists( $room_id )
    {
        // Loads the number of rooms matching the provided room ID
        $sql = "SELECT COUNT(id) AS the_count FROM rooms WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $room_exists = (bool) $stmt->fetch(PDO::FETCH_OBJ)->the_count;
        $stmt->closeCursor();

        return $room_exists;
    }

}

使用(bool)将计数显式转换为布尔值意味着该方法将总是返回TRUEFALSE,这对于唯一目的是检查某个东西是否存在的方法很有帮助。

开房

对于一个已经关闭的房间,重新打开它非常简单,只需将具有给定 ID 的房间的rooms表中的is_active列设置为1

将粗体显示的代码添加到class.room_model.inc.php :

        $room_exists = (bool) $stmt->fetch(PDO::FETCH_OBJ)->the_count;
        $stmt->closeCursor();

        return $room_exists;
    }

    /**
     * Sets a given room's status to "open"
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              array   An array of data about the room
     */
    public function open_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=1 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

}

关闭房间

关闭一个房间与打开一个房间的过程相同,只是在rooms表中is_active列被设置为0。将以下粗体代码插入Room_Model :

    public function open_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=1 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

    /**
     * Sets a given room's status to "closed"
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              array   An array of data about the room
     */
    public function close_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=0 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

}

装货间信息

加载房间信息可以说是应用中最复杂的查询。它要求如下:

  • room_owners工作台连接到rooms工作台;然后将presenters表连接到该表,形成一个完整的数据集
  • rooms表中加载idnameis_active
  • id更名为room_id,将name更名为room_name
  • 从演示者表格中加载idnameemail
  • id更名为presenter_id,将name更名为presenter_name

使用添加到class.room_model.inc.php中的以下粗体代码所示的查询完成上述步骤:

    public function close_room( $room_id )
    {
        $sql = "UPDATE rooms SET is_active=0 WHERE id = :room_id";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $stmt->closeCursor();

        return array(
            'room_id' => $room_id,
        );
    }

    /**
     * Retrieves details about a given room
     *
     * @param   $room_id    int     The ID of the room being checked
     * @return              array   An array of data about the room
     */
    public function get_room_data( $room_id )
    {
        $sql = "SELECT
                    rooms.id AS room_id,
                    presenters.id AS presenter_id,
                    rooms.name AS room_name,
                    presenters.name AS presenter_name,
                    email, is_active
                FROM rooms
                LEFT JOIN room_owners
                    ON( rooms.id = room_owners.room_id )
                LEFT JOIN presenters
                    ON( room_owners.presenter_id = presenters.id )
                WHERE rooms.id = :room_id
                LIMIT 1";
        $stmt = self::$db->prepare($sql);
        $stmt->bindParam(':room_id', $room_id, PDO::PARAM_INT);
        $stmt->execute();
        $room_data = $stmt->fetch(PDO::FETCH_OBJ);
        $stmt->closeCursor();

        return $room_data;
    }

}

向房间控制器添加表单处理程序

应用后端的最后一步是将表单动作和动作处理程序添加到Room控制器中。

将表单动作添加到房间控制器

Room控制器支持四种动作:

  • 加入房间
  • 创建房间
  • 开房
  • 关闭房间

将以下粗体代码添加到Room类的构造函数中,以添加对这四个动作的支持:

public function __construct( $options )
{
    parent::__construct($options);

    $this->model = new Room_Model;

    // Checks for a form submission
    $this->actions = array(
        'join'   => 'join_room',
        'create' => 'create_room',
        'open'   => 'open_room',
        'close'  => 'close_room',
    );

    if (array_key_exists($options[0], $this->actions)) {
        $this->handle_form_submission($options[0]);
        exit;
    } else {
        $this->room_id = isset($options[0]) ? (int) $options[0] : 0;
        if ($this->room_id===0) {
            throw new Exception("Invalid room ID supplied");
        }
    }

    $this->room         = $this->model->get_room_data($this->room_id);
    $this->is_presenter = $this->is_presenter();
    $this->is_active    = (boolean) $this->room->is_active;
}

该数组为应该调用的方法创建一个操作映射。接下来,if...else语句检查一个有效的动作,如果传递了一个动作,它就调用handle_form_submission()来处理它。

加入房间

当用户试图加入一个房间时,控制器需要首先使用来自Room_Modelroom_exists()方法检查它是否存在。如果是,用户应该被重定向到所请求的房间;否则,他们应该会收到一条错误消息。

将以下粗体代码添加到Room控制器中:

protected function output_questions(  )
{
    $controller = new Question(array($this->room_id));

    // Allows for different output for presenters vs. attendees
    $controller->is_presenter = $this->is_presenter;

    return $controller->output_view();
}

/**
 * Checks if a room exists and redirects the user appropriately
 *
 * @return void
 */
protected function join_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);

    // If the room exists, creates the URL; otherwise, sends to a 404
    if ($this->model->room_exists($room_id)) {
        $header = APP_URI . 'room/' . $room_id;
    } else {
        $header = APP_URI . 'no-room';
    }

    header("Location: " . $header);
    exit;
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

创建新房间

要创建一个新房间,控制器需要整理演示者的姓名、电子邮件和房间名称;使用房间模型的create_room()方法存储它们;然后检查房间是否创建成功。它还应该设置一个 cookie,将房间的创建者标识为演示者。

class.room.inc.php中,添加以下粗体代码:

    if ($this->model->room_exists($room_id)) {
        $header = APP_URI . 'room/' . $room_id;
    } else {
        $header = APP_URI . 'no-room';
    }

    header("Location: " . $header);
    exit;
}

/**
 * Creates a new room and sets the creator as the presenter
 *
 * @return array Information about the updated room
 */
protected function create_room(  )
{
    $presenter = $this->sanitize($_POST['presenter-name']);
    $email     = $this->sanitize($_POST['presenter-email']);
    $name      = $this->sanitize($_POST['session-name']);

    // Store the new room and its various associations in the database
    $output = $this->model->create_room($presenter, $email, $name);

    // Make sure valid output was returned
    if (is_array($output) && isset($output['room_id'])) {
        $room_id = $output['room_id'];
    } else {
        throw new Exception('Error creating the room.');
    }

    // Makes the creator of this room its presenter
    setcookie('presenter_room_' . $room_id, 1, time() + 2592000, '/');

    return $output;
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

重新打开一个封闭的房间

为了重新打开一个已经关闭的房间,清理后的房间 ID 被传递给模型的open_room()方法。

    // Makes the creator of this room its presenter
    setcookie('presenter_room_' . $room_id, 1, time() + 2592000, '/');

    return $output;
}

/**
 * Marks a given room as active
 *
 * @return array Information about the updated room
 */
protected function open_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);
    return $this->model->open_room($room_id);
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

关闭房间

关闭一个房间和打开一个房间几乎是一样的,只是调用了close_room()方法。

protected function open_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);
    return $this->model->open_room($room_id);
}

/**
 * Marks a given room as closed
 *
 * @return array Information about the updated room
 */
protected function close_room(  )
{
    $room_id = $this->sanitize($_POST['room_id']);
    return $this->model->close_room($room_id);
}

/**
 * Determines whether or not the current user is the presenter
 *
 * @return boolean  TRUE if it's the presenter, otherwise FALSE
 */
protected function is_presenter(  )
{

全部测试完毕

此时,您的应用代码已经完成,可以进行测试了。让我们运行每一个可用的行动,以验证一切都在按计划进行。

创建您的第一个房间

首先,在您选择的浏览器中加载应用,并输入新房间的详细信息(参见图 9-1 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-1。在应用的主页上创建新房间

点击创建你的房间,你将被带到新的空房间(见图 9-2 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-2。新创建的房间

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意在一些服务器配置中,运行PDO::__construct()时可能会出现 MySQL 错误。这通常意味着您需要将您的php.ini文件指向mysql.sock的正确位置。在https://gist.github.com/jlengstorf/5184301有一个简单的演练,如果您在 Google 上搜索错误消息,还有几个可用的演练。

关闭房间

关闭房间,测试演示者控制是否正常工作。点击关闭此房间按钮,房间变为非活动状态(参见图 9-3 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-3。封闭的房间

重新开放房间

确保房间可以通过点击打开此房间按钮重新打开,这将使房间回到其最初的活动状态(参见图 9-4 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-4。重新开放的房间

加入房间

接下来,打开不同的浏览器(这意味着完全不同的应用:Firefox、Safari、Opera 或 Internet Explorer,如果你开始使用 Google Chrome 的话)并导航到http://rwa.local/

根据本节中的数字,输入 1 作为房间的 ID,并点击加入此房间按钮(参见图 9-5 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-5。从主页加入房间(使用不同的浏览器)

房间打开,您现在可以看到“提问”表单(参见图 9-6 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-6。您作为与会者查看房间时,会看到“提问”表单

问你的第一个问题

通过在表格字段中键入新问题,测试与会者是否可以提出新问题(参见图 9-7 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-7。通过“提问”表单提出新问题

点击提问按钮后,新问题被创建并显示为已被您投票通过(见图 9-8 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-8。问题被创建;创造者的投票已经被计算了

投票表决这个问题

为了测试对问题的投票,您需要打开第三个浏览器,这样您就可以作为尚未对新问题投票的与会者加入房间(参见图 9-9 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-9。未投票的与会者看到的问题

点击投票按钮,将投票数增加 1(见图 9-10 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-10。收到第二次投票后的问题

回答问题

回到第一个浏览器——您的用户是演示者的浏览器——重新加载以查看新问题(参见图 9-11 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-11。演示者仪表盘上的问题,完成计票

现在点击回答按钮,将问题标记为已回答(参见图 9-12 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-12。演示者眼中的已回答问题

通过检查其他两个浏览器中的任何一个,验证这也能正确显示给与会者(参见图 9-13 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 9-13。与会者眼中的已回答问题

摘要

从这一章到上一章,你已经在最后 100 页左右的篇幅里涉及了很多内容。您现在应该有一个功能齐全的 MVC 应用,允许创建、关闭和重新打开房间;以及提问、投票和回答问题。

在下一章中,您将向应用添加实时事件通知,并实现 JavaScript 效果来动画显示这些事件。

十、实现实时事件和 jQuery 效果

至此,你有了一个全功能的 app。然而,为了让这个特殊的应用有用,它需要实现实时功能,这样它的用户就不会被迫不断地重新加载以获取新数据。

在这一章中,你将把应用挂接到 Pusher 上,并在后端添加代码来创建实时事件。您还将使用 Pusher 的 JavaScript 应用编程接口(API)来订阅这些事件,并使用 jQuery 来制作应用的动画,这样就可以在屏幕上以一种和谐的方式操作新数据。

哪些事件需要实时增强?

  • 关闭房间
  • 开房
  • 问问题
  • 投票表决一个问题
  • 回答问题

添加所需的凭证和库

在开始添加 realtime 之前,您需要确保手头上有所有适当的凭据和库来配置应用。

获取您的 Pusher API 证书

Pusher API 要求您的应用使用应用密钥、应用密码和应用 ID 进行身份验证。

要获取它们,请在http://pusher.com登录您的帐户,并从您的仪表板左上角选择“添加新应用”。将你的新应用命名为“实时网络应用”,不要勾选下面的两个框(见图 10-1 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-1。在 Pusher 中创建新应用

点击“创建应用”,然后在下一个屏幕上点击“API 访问”,调出您的 API 凭证(见图 10-2 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-2。在 Pusher 仪表盘上查看应用的 API 凭证

将推送器 API 凭证添加到配置文件

现在您已经有了 API 凭证,它们需要包含在应用中。为此,向system/config/config.inc.php添加三个新的常量——以粗体显示:

//-----------------------------------------------------------------------------
// Database credentials
//-----------------------------------------------------------------------------

$_C['DB_HOST'] = 'localhost';
$_C['DB_NAME'] = 'rwa_app';
$_C['DB_USER'] = 'root';
$_C['DB_PASS'] = '';

//-----------------------------------------------------------------------------
// Pusher credentials
//-----------------------------------------------------------------------------

$_C['PUSHER_KEY']    = '9570a71016cf9861a52b';
$_C['PUSHER_SECRET'] = '65cc09ede8e2c18701cc';
$_C['PUSHER_APPID']  = '37506';

//-----------------------------------------------------------------------------
// Enable debug mode (strict error reporting)
//-----------------------------------------------------------------------------

$_C['DEBUG'] = TRUE;

为 Pusher 下载 PHP API 包装

对于应用的后端部分,我们需要使用 API 包装器来使访问 Pusher 变得轻而易举。

https://github.com/pusher/pusher-php-server下载 API 包装。ZIP 将包含几个文件和目录,但你需要抓取的唯一一个是在lib/Pusher.php,你现在应该在你的应用中复制到system/lib/

包括应用中的 PHP API 包装器

既然 Pusher API 包装器已经在应用的目录结构中,就需要包含它以供使用。在index.php中,将以下粗体代码添加到初始化块中:

// Starts the session
if (!isset($_SESSION)) {
    session_start();
}

// Loads the configuration variables
require_once SYS_PATH . '/config/config.inc.php';

// Loads Pusher
require_once SYS_PATH . '/lib/Pusher.php';

// Turns on error reporting if in debug mode
if (DEBUG===TRUE) {
    ini_set('display_errors', 1);
    error_reporting(E_ALL^E_STRICT);
} else {
    ini_set('display_errors', 0);
    error_reporting(0);
}

加载 Pusher 的 JavaScript API 包装器

对于实时实现的前端部分,应用需要包含 Pusher 的 JavaScript API 包装器。在system/inc/footer.inc.php中,添加以下粗体代码:

    </ul>
</footer>

<script src="[`js.pusher.com/1.12/pusher.min.js"></script`](http://js.pusher.com/1.12/pusher.min.js"></script)>

</body>

</html>

正在加载 jQuery

为了获得效果,您的应用将需要 jQuery 库。将其添加到页脚中的 Pusher JS 之后:

    </ul>
</footer>

<script src="http://js.pusher.com/1.12/pusher.min.js"></script>
<script src="[`code.jquery.com/jquery-1.8.2.min.js"></script`](http://code.jquery.com/jquery-1.8.2.min.js"></script)>

</body>

</html>

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意该应用中的代码只经过了 jQuery 1 . 8 . 2 版本的测试。较新的版本可能会带来一些问题,只有经过全面测试后才能使用。

在后端实现实时

为了让球滚动起来,需要在应用的后端创建和触发事件。

创建事件

由于应用处理表单提交的方式——所有表单都通过一个表单处理程序方法传递——发送实时事件通知只需要几行代码,这些代码将被添加到抽象Controller类中的handle_form_submission()方法中。

打开system/core/class.controller.inc.php并插入以下粗体代码:

protected function handle_form_submission( $action )
{
    if ($this->check_nonce()) {

       // Calls the method specified by the action
       $output = $this->{$this->actions[$action]}();

       if (is_array($output) && isset($output['room_id'])) {
          $room_id = $output['room_id'];
       } else {
          throw new Exception('Form submission failed.');
       }

       // Realtime stuff happens here
       $pusher  = new Pusher(PUSHER_KEY, PUSHER_SECRET, PUSHER_APPID);
       $channel = 'room_' . $room_id;
       $pusher->trigger($channel, $action, $output);

       header('Location: ' . APP_URI . 'room/' . $room_id);
       exit;
   } else {
       throw new Exception('Invalid nonce.');
    }
}

创建一个新的Pusher对象并存储在$pusher变量中,然后使用其 ID 创建房间的通道。使用动作名作为事件名,使用发送输出数组供客户端使用的trigger()方法在房间的通道上触发一个新事件。

测试实时事件

为了确保你的实时事件在后端被触发,回到你的 Pusher 仪表盘,打开你的应用的调试控制台。打开此页面,在新标签或浏览器中导航至http://rwa.local,然后创建一个新房间。

创建房间后,查看调试控制台,您会看到类似于图 10-3 的内容。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-3。推进器调试控制台实时显示房间的创建

接下来,关闭房间;然后重新打开它。再次检查控制台,您将看到closeopen事件被触发(参见图 10-4 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-4。Pusher 控制台显示关闭和重新打开房间的事件

在第二个浏览器中,加入房间(本例中为 ID 5)并提出问题。新的连接以及触发的ask事件显示在控制台上(见图 10-5 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-5。控制台显示新的问题标记,由 ask 事件发送

在第三个浏览器中,对问题进行投票,以查看触发的投票事件(参见图 10-6 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-6。控制台显示发生的投票事件

最后,返回到创建房间的浏览器,将问题标记为已回答。这触发了控制台中的answer事件(参见图 10-7 )。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-7。控制台中的回答事件

在前端实现实时

现在,应用的后端正在触发事件,前端需要监听它们。

订阅频道

第一步是创建一个 JavaScript Pusher对象,并用它来订阅房间的频道。

确定通道名称

在订阅频道之前,您首先需要创建一个新的模板变量来保存正确的频道名称。在index.php中,添加以下粗体代码以生成通道名称:

require_once SYS_PATH . '/inc/header.inc.php';

$controller->output_view();

// Configures the Pusher channel if we're in a room
$channel = !empty($uri_array[0]) ? 'room_' . $uri_array[0] : 'default';

require_once SYS_PATH . '/inc/footer.inc.php';

添加频道订阅 JavaScript

现在频道名称已经确定,创建一个新的Pusher对象,并通过在system/inc/footer.inc.php中添加以下粗体代码来订阅该频道:

</footer>

<script src="http://js.pusher.com/1.12/pusher.min.js"></script>
<script src="http://code.jquery.com/jquery-1.8.2.min.js"></script>
<script>
    var pusher  = new Pusher('<?php echo PUSHER_KEY; ?>'),
        channel = pusher.subscribe('<?php echo $channel; ?>');
</script>

</body>

</html>

绑定到事件

该应用现在订阅了一个频道,但此时它仍需要监听各个事件。

创建一个初始化 JavaScript 文件

为了保持页脚的整洁,在assets/scripts/中创建一个名为init.js的新文件,并用下面的代码初始化它:

/**
 * Initialization script for Realtime Web Apps
 */
(function($) {

})(jQuery);

这个文件将包含应用的 JavaScript 的其余部分。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 提示用闭包[脚注]包装你的应用脚本可以防止与其他使用美元符号($)快捷方式的库发生冲突,比如 Prototype 和 MooTools。

通过将以下粗体代码插入页脚,将此文件加载到您的应用中:

<script src="http://js.pusher.com/1.12/pusher.min.js"></script>
<script src="http://code.jquery.com/jquery-1.8.2.min.js"></script>
<script>
    var pusher  = new Pusher('<?php echo PUSHER_KEY; ?>'),
        channel = pusher.subscribe('<?php echo $channel; ?>');
</script>
<script src="<?php echo APP_URI; ?>assets/scripts/init.js"></script>

</body>

</html>

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意Pusher对象初始化和通道订阅被直接放在页脚中,以利用 PHP 支持的模板。

为每个支持的动作添加事件绑定

对于每一个需要实时响应的动作,我们的应用都会触发一个需要“监听”的事件。Pusher 通过bind()方法使这变得非常容易,这对于任何在以前的项目中使用过 JavaScript 的开发人员来说都应该很熟悉。

bind()方法将被监听的事件的名称作为第一个参数,将事件发生时要执行的函数作为第二个参数。

使用以下粗体代码为应用中的每个事件绑定一个函数:

(function($) {

    channel.bind('close',  function(data){  });
    channel.bind('open',   function(data){  });
    channel.bind('ask',    function(data){  });
    channel.bind('vote',   function(data){  });
    channel.bind('answer', function(data){  });

})(jQuery);

这些绑定实际上不会增加应用的开销,因此同时绑定所有五个不会影响性能。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意这些方法现在还不做任何事情;您将在下一节中添加该功能。

添加效果

该应用现在发送和接收实时事件,所以剩下要做的就是添加新数据的效果。

处理房间事件

当演示者关闭一个房间时,需要立即让与会者知道,这样他们就不会试图问任何新问题或投新票。

类似地,如果一个演示者重新打开一个房间,所有与会者都应该立即知道这个房间现在又打开了。

由于标记在封闭的房间和开放的房间之间变化很大,所以提醒与会者房间的开放状态发生变化的最直接、最不容易出错的方法是简单地刷新页面。

init.js,创建一个名为room的新对象,它有两个方法:open()close()。两者都应该在被调用时重新加载页面。

相应的事件也应该触发这些方法。为此,将以下粗体代码添加到init.js:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){  });
    channel.bind('vote',   function(data){  });
    channel.bind('answer', function(data){  });

    var room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        };

})(jQuery);

请注意,我们已经选择在演示者关闭房间时自动重新加载页面。通常情况下,你不希望在没有用户输入的情况下重新加载一个页面,因为这可能会造成混乱,但是我们有充分的理由这样做。在这种特殊情况下,如前所述,当房间关闭时,页面上的许多标记会发生变化。此外,用户不能向关闭的房间提交新问题,因此关闭房间的演示者应该是破坏性的;否则,与会者可能会花额外的时间处理一个无论如何都无法提交的问题,这可能比仅仅意识到房间现在已经关闭,他们应该给演示者发电子邮件更令人沮丧。

添加带动画的新问题

当一个新问题被提出时,它应该立即提供给查看房间的每个人。为了使新数据的引入不那么刺耳,应该添加一个动画。

在第八章中,当动作处理器方法create_question()被添加到Question控制器中以询问新问题时,您已经为该事件生成了视图,因此格式化的 HTML 将在data对象中发送。

但是,因为后端不可能知道视图当前使用的随机数,所以我们需要读取随机数并将其插入到所有新生成的随机数字段的value属性中,然后用slideDown()将问题添加到列表的顶部:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){ question.ask(data); });
    channel.bind('vote',   function(data){  });
    channel.bind('answer', function(data){  });

    varnonce = $('input[name=nonce]:eq(0)').val(),
        room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        },
        question = {
            ask: function(data){
                $(data.markup)
                    .find('input[name=nonce]').val(nonce).end()
                    .hide().prependTo('#questions').slideDown('slow');
            }
        };

})(jQuery);

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传 注意为了保持 JavaScript 简洁,我们在这个脚本中对所有三个变量只使用了一次var声明。这主要是一种风格上的选择,但是也有一些观点认为使用这种方法可以获得微小的性能提升。

为问题添加投票

当与会者对某个问题进行投票时,投票按钮旁边的计数应该会更新。然而,我们想吸引更多的注意力,所以让我们添加一个微妙的动画。

将动画添加到样式表

因为 CSS3 引入了动画,并且因为大多数现代浏览器支持 CSS3 动画的硬件加速,所以您的应用将使用关键帧 CSS 动画,而不是 jQuery 动画。

为了实现这一点,您首先必须确定哪个类(在本例中为.new-vote)将触发动画,然后设置它。对于这个名为vote的动画,我们将快速淡出问题,然后通过调整不透明度再次淡入。这将被执行或迭代两次。

不幸的是,您将需要特定于供应商的前缀来确保动画在所有浏览器中都能工作,所以应该是快速添加的内容变成了相当大量的 CSS。

将以下代码添加到assets/styles/main.css的底部:

/*
 * ANIMATION
 *****************************************************************************/

#questions li.new-vote {
    -webkit-animation-name: vote;
    -webkit-animation-duration: 0.5s;
    -webkit-animation-timing-function: ease-in-out;
    -webkit-animation-iteration-count: 2;

    -moz-animation-name: vote;
    -moz-animation-duration: 0.5s;
    -moz-animation-timing-function: ease-in-out;
    -moz-animation-iteration-count:2;

    -ms-animation-name: vote;
    -ms-animation-duration: 0.5s;
    -ms-animation-timing-function: ease-in-out;
    -ms-animation-iteration-count: 2;

    animation-name: vote;
    animation-duration: 0.5s;
    animation-timing-function: ease-in-out;
    animation-iteration-count: 2;
}

@-webkit-keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

@-moz-keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

@-ms-keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

@keyframes vote {
    0% { opacity: 1; }
    50% { opacity: .4; }
    100% { opacity: 1; }
}

用一个类触发动画

现在动画已经就绪,JavaScript 需要做的就是添加一个类来触发它。

除了动画之外,脚本还需要更新投票计数,因为可能会有多个人为一个问题投票,所以在动画完成后删除该类,以便它可以被多次触发。

将以下粗体代码添加到init.js以完成投票效果:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){ question.ask(data); });
    channel.bind('vote',   function(data){ question.vote(data); });
    channel.bind('answer', function(data){  });

    var nonce = $('input[name=nonce]:eq(0)').val(),
        room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        },
        question = {
            ask: function(data){
                $(data.markup)
                    .find('input[name=nonce]').val(nonce).end()
                    .hide().prependTo('#questions').slideDown('slow');
            },
            vote: function(data){
                var question  = $('#question-'+data.question_id),
                    cur_count = question.data('count'),
                    new_count = cur_count+1;

                // Updates the count
                question
                    .attr('data-count', new_count)
                    .data('count', new_count)
                    .addClass('new-vote');

                setTimeout(1000, function(){
                    question.removeClass('new-vote');
                });
            }
        };

})(jQuery);

测试动画

要查看实际效果,请使用两个浏览器加入一个房间(确保没有一个浏览器是演示者),并将它们并排放置,以便您可以同时看到两个浏览器。

在一个浏览器中,问一个新问题;当ask事件被触发时,它将被动态添加到另一个浏览器窗口。

在另一个浏览器中,对新问题进行投票。提交投票时,观看第一个浏览器:它将在动画中运行一遍,然后返回正常状态。很难在静态图像中演示这一点,但是图 10-8 显示了正在进行的动画。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-8。当使用左边的浏览器对首要问题投票时,动画会在右边的浏览器中触发

用动画和重新排序回答问题

当一个问题被标记为已回答时,它应该淡出,然后从列表中移除(通过向上的动画幻灯片)并重新附加到底部(也通过动画幻灯片),以便为未回答的问题腾出空间。

将以下粗体显示的代码添加到init.js中,使其发生:

(function($) {

    channel.bind('close',  function(data){ room.close(data); });
    channel.bind('open',   function(data){ room.open(data); });
    channel.bind('ask',    function(data){ question.ask(data); });
    channel.bind('vote',   function(data){ question.vote(data); });
    channel.bind('answer', function(data){ question.answer(data); });

    var nonce = $('input[name=nonce]:eq(0)').val(),
        room = {
            open: function(data){
                location.reload();
            },
            close: function(data){
                location.reload();
            }
        },
        question = {
            ask: function(data){
                $(data.markup)
                    .find('input[name=nonce]').val(nonce).end()
                    .hide().prependTo('#questions').slideDown('slow');
            },
            vote: function(data){
                var question  = $('#question-'+data.question_id),
                    cur_count = question.data('count'),
                    new_count = cur_count+1;

                // Updates the count
                question
                    .attr('data-count', new_count)
                    .data('count', new_count)
                    .addClass('new-vote');

                setTimeout(1000, function(){
                    question.removeClass('new-vote');
                });
            },
            answer: function(data){
                var question = $("#question-"+data.question_id),
                    detach_me = function() {
                        question
                            .detach()
                            .appendTo('#questions')
                            .slideDown(500);
                    }

                question
                    .addClass('answered')
                    .delay(1000)
                    .slideUp(500, detach_me);
            }
        };

})(jQuery);

测试答题

要查看标记为已回答的问题在与会者看来是什么样子,请打开两个浏览器并将窗口并排放置,以便您可以同时看到两个窗口。在一个浏览器中,创建一个新房间;在另一个房间里,问一个问题。

问题将显示在创建房间的浏览器上,此时您可以将问题标记为已回答。在与会者视图中,问题将淡化、消失,然后以“已回答”状态重新附加(参见图 10-9 )。因为房间里只有一个问题,所以不会演示如何将问题移到列表的底部,但是如果您愿意,您可以进行自己的实验来查看实际效果。

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 10-9。一个在与会者和演示者视图中都被标记为已回答的问题

摘要

在这一章中,你确切地了解了实现实时事件是多么的快捷和容易,以及将它们集成到你的应用中的效果。

至此,应用已经完成,您已经准备好开始构建自己令人惊叹的实时应用了。请在 Twitter 上找到你的作者——@ jlengstorf 和@ leggetter——并分享你的创作。

朋友,欢迎来到网页设计的未来。

转载请注明出处或者链接地址:https://www.qianduange.cn//article/15479.html
标签
VKDoc
评论
发布的文章

安装Nodejs后,npm无法使用

2024-11-30 11:11:38

大家推荐的文章
会员中心 联系我 留言建议 回顶部
复制成功!