有需要的小伙伴直接拿代码即可,不需要下载依赖。

评论组件如下:

创建 comment.vue 文件。

表情组件 VueEmoji.vue 在评论组件中使用。

<template>
  <div class="comment">
    <div class="flex_box">
      <h2>评论 {{total}}</h2>
      <p>文明上网理性发言</p>
    </div>
    <div class="comment-header">
      <div class="header_children">
        <i class="el-icon-user-solid img_width"></i>
        <el-input
          :placeholder="placeholderText"
          v-model="context"
          class="input"
          type="textarea"
          resize="none"
          size="mini"
          clearable
          :maxlength="contentLength"
          @focus="isShowSecReply(undefined)"
        ></el-input>
      </div>
      <div class="header_btn">
        <!-- 表情 -->
        <vue-emoji @chooseEmoji="chooseEmoji"></vue-emoji>
        <el-button
          type="primary"
          style="height: 40px"
          @click="addComment()"审核
        >{{ buttonText }}</el-button>
      </div>
    </div>
    <div v-loading="bigLoading">
      <div class="comment-body" v-for="(item, index) in comments" :key="item.id + '' + index">
        <!-- 一级评论 -->
        <div class="first-comment">
          <i class="el-icon-user-solid img_width"></i>
          <div class="content">
            <!-- 一级评论用户昵称 -->
            <div style="display: flex;align-items: center;">
              <h3>{{ item.memberName }}</h3>
              <span v-if="item.auditStatus == '0'" style="color: #ddab16;margin-left: 10px">(待审核)</span>
            </div>
            <!-- 一级评论发布时间 -->
            <span>{{ item.createTime }}</span>
            <!-- 一级评论评论内容 -->
            <p>{{ item.commentContent }}</p>
            <!-- 一级评论评论点赞 -->
            <div class="comment-right">
              <i
                class="el-icon-chat-dot-round"
                @click="isShowSecReply(item)"
              >回复</i>
              <i
                v-if="userInfor.memberName == item.memberName"
                class="el-icon-delete"
                @click="deleteComment(item, undefined)"
              >删除</i>
            </div>
            <!-- 回复一级评论 -->
            <div class="reply-comment" v-show="isShowSec === item.id">
              <el-input
                :placeholder="placeholderText"
                class="input"
                v-model.trim="replyContext"
                :maxlength="contentLength"
                clearable
              ></el-input>
              <div class="flex_one_box">
                <!-- 表情 -->
                <vue-emoji @chooseEmoji="chooseEmoji" :layer="comments"></vue-emoji>
                <el-button
                  type="primary"
                  size="mini"
                  class="reply-button"
                  @click="addComment(item)"
                >回复</el-button>
              </div>
            </div>
            <!-- 次级查看更多 -->
            <div class="li_top" v-if="item.commentReplyInfoList?.length > 0" @click="hanlePublicTotal(item)">
              <span style="color: rgb(21 66 231);">共{{item.commentReplyCount}}条评论</span>
              <i class="el-icon-caret-bottom" v-show="item.publicTotal"></i>
              <i class="el-icon-caret-top" v-show="!item.publicTotal"></i>
            </div>
            <!-- 次级评论 -->
            <ul v-infinite-scroll="load(item.commentReplyInfoList)" infinite-scroll-delay="200" style="overflow:auto" class="infinite_list" v-if="item.publicTotal"> 
              <li v-for="(reply, index) in item.commentReplyInfoList" :key="reply.id + '' + index">
                <!-- 次级评论头像,该用户没有头像则显示默认头像 -->
                <template>
                  <div class="second-comment">
                    <i class="el-icon-user-solid img_width"></i>
                    <div class="content">
                      <!-- 次级评论用户昵称 -->
                      <div style="display: flex;align-items: center;">
                        <h3>{{ reply.memberName }}</h3>
                        <span v-if="reply.auditStatus == '0'" style="color: #ddab16;margin-left: 10px">(待审核)</span>
                      </div>
                      <!-- 次级评论评论时间 -->
                      <span>{{ reply.createTime }}</span>
                      <span class="to_reply">{{ reply.memberName }}</span>
                      回复
                      <span class="to_reply">{{ reply.replyMemberName }}</span>:
                      <p>{{ reply.commentContent }}</p>
                      <!-- 次级评论评论点赞 -->
                      <div class="comment-right">
                        <i
                          class="el-icon-chat-dot-round"
                          @click="isShowSecReply(reply)"
                        >回复</i>
                        <i
                          v-if="userInfor.memberName == reply.memberName"
                          class="el-icon-delete"
                          @click="deleteComment(item, reply)"
                        >删除</i>
                      </div>
                      <div class="reply-comment" v-show="isShowSec === reply.id">
                        <el-input
                          :placeholder="placeholderText"
                          class="input"
                          v-model.trim="replyContext"
                          :maxlength="contentLength"
                          clearable
                        ></el-input>
                        <div class="flex_one_box">
                          <!-- 表情 -->
                          <vue-emoji @chooseEmoji="chooseEmoji" :layer="item.commentReplyInfoList"></vue-emoji>
                          <el-button
                            type="primary"
                            size="mini"
                            class="reply-button"
                            @click="addComment(item,reply)"
                          >回复</el-button>
                          </div>
                      </div>
                    </div>
                  </div>
                </template>
              </li>
              <el-button class="loading_p" type="primary" size="mini" v-if="item.moreValue" :loading="childrenMorelist.loading" @click="changeChildrenMore(item)">加载更多</el-button>
            </ul>
          </div>
        </div>
      </div>
    </div>
    <!-- 一级查看更多 -->
    <el-button type="info" class="moreBtn" :loading="morelist.loading" @click="changeMore" v-if="morelist.value">
      查看更多评论
      <i class="el-icon-caret-bottom"></i> 
    </el-button>
    <!-- <div v-else class="more_div">暂无更多评论</div> -->
    <!-- 暂无评论的空状态 -->
    <el-empty :description="emptyText" v-if="comments.length === 0"></el-empty>
  </div>
</template>
<script>
export default {
  props: {
    sourceType: {
      type: String,
      require: true
    },
    articleId: {
      //评论所属文章 id
      type: String
    },
    emptyText: {
      // 评论为空的时候显示的文字
      type: String,
      default: "期待你的评论!"
    },
    buttonText: {
      // 按钮文字
      type: String,
      default: "评论"
    },
    contentLength: {
      // 评论长度
      type: Number,
      default: 150
    },
    placeholderText: {
      // 默认显示文字
      type: String,
      default: "请输入最多150字的评论..."
    }
  },
  data() {
    return {
      bigLoading: false,
      userInfor: {},
      params: {
        pageNum: "1",
        pageSize: "5",
        orderByColumn: "createTime",
        isAsc: "asc",
        sourceType: this.sourceType,
        mainId: "",
      },
      pantPageNum: "1", // 父级查看更多
      total: 0,
      comments: [
        {
          auditStatus: "1", // 审核状态
          auditTime: "2024-05-27", // 审核时间
          auditUserId: null,
          commentContent: "999", // 内容
          commentReplyCount: 1, // 二级回复条数
          commentReplyInfoList: [], // 二级回复数组
          createBy: "admin", // 
          createTime: "2024-05-27 01:39:18",
          id: 242,
          ids: null,
          mainId: 0,
          memberId: 1, // 哪条详情下评论的详情id
          memberName: "admin", // 用户名
          operationUserName: "自动通过",
          parentId: 0, // 父级id
          remark: null,
          replyMemberName: null, // 回复的用户名
          sourceId: "20",
          sourceType: "1", // 类型
          title: null,
          updateBy: "系统"
        }
      ], // 获取得到的评论
      context: "", // 评论内容
      replyContext: "", //一级评论回复
      isShowSec: "", //是否显示次级回复框
      isClickId: "", //记录点击回复的评论id
      morelist: {
        index: '2',
        value: false,
        loading: false,
      },
      childrenMorelist: {
        index: '2',
        childrenId: "",
        childrenPageNum: "1", // 子级查看更多
        childrenTotal: 0,
        value: false,
        loading: false,
      },
    };
  },
  mounted() {
    this.userInfor = localStorage.getItem("userInfor") ? JSON.parse(localStorage.getItem("userInfor")) : {};
    // 获取评论数据
    this.getCommentList();
  },
  computed: {
    isLogin() {
      return this.$store.getters.isLogin;
    },
  },
  methods: {
    chooseEmoji(layer,val) {
      if(layer) {
        this.replyContext += val;
        return;
      }
      this.context += val;
    },
    // 查看更多评论
    async changeMore() {
      this.morelist.loading = true;
      this.pantPageNum++;
      this.params.pageNum = this.pantPageNum;
      console.log("this.params.pageNum",this.params.pageNum);
      let res = await this.getNewList();
      res.rows.map(item => {
        this.$set(item,'publicTotal',false)
      })
      this.comments.push(...res.rows);
      this.morelist.loading = false;
      this.total = res.total;
      if(res.total > this.params.pageNum * this.params.pageSize) {
        this.morelist.value = true;
      }else {
        this.morelist.value = false;
      }
    },
    // 加载更多
    async changeChildrenMore(item) {
      if(this.childrenMorelist.childrenId && this.childrenMorelist.childrenId != item.id) {
        this.childrenMorelist.childrenPageNum = "1";
      }
      this.childrenMorelist.childrenId = item.id;
      this.childrenMorelist.loading = true;
      this.childrenMorelist.childrenPageNum++;
      this.params.pageNum = this.childrenMorelist.childrenPageNum;
      this.params.mainId = item.id;
      let res = await this.getNewList();
      this.childrenMorelist.childrenTotal = res.total;
      console.log("res",res);
      item.commentReplyInfoList.push(...res.rows);
      this.childrenMorelist.loading = false;
      if(res.total > this.params.pageNum * this.params.pageSize) {
        item.moreValue = true;
      }else {
        item.moreValue = false;
      }
    },
    load(list) {
      list += 2
    },
    // 唤起文件选择
    handleClick() {
      this.$refs.avatar.click();
    },
    // 获取本篇文章所有评论
    async getCommentList() {
      try {
        this.comments = [];
        this.bigLoading = true;
        // 获取某篇文章下的所有评论
        const res = await this.$get(`/api/comment/list?pageNum=${this.params.pageNum}&pageSize=${this.params.pageSize}&orderByColumn=${this.params.orderByColumn} desc,auditStatus&isAsc=${this.params.isAsc}&sourceType=${this.params.sourceType}&mainId=${this.params.mainId}&sourceId=${this.$route.query.id}`)
        this.bigLoading = false;
        this.comments = res.rows; //评论列表
        this.total = res.total; //评论总数
        if(this.total > this.params.pageSize) {
          this.morelist.value = true;
        }else {
          this.morelist.value = false;
        }
        this.comments.map(item => {
          if(item.commentReplyCount > 5) {
            item.moreValue = true;
          }else {
            item.moreValue = false;
          }
          this.$set(item,'publicTotal',false)
        }) 
      } catch (err) {
        this.bigLoading = false;
        this.$message.error(err);
      }
    },
    async getNewList() {
      let res = await this.$get(`/api/comment/list?pageNum=${this.params.pageNum}&pageSize=${this.params.pageSize}&orderByColumn=${this.params.orderByColumn} desc,auditStatus&isAsc=${this.params.isAsc}&sourceType=${this.params.sourceType}&mainId=${this.params.mainId}&sourceId=${this.$route.query.id}`);
      console.log("res1111",res);
      return res;
    },
    // 控制二级条数显示盒子
    hanlePublicTotal(item) {
      item.publicTotal = !item.publicTotal;
      console.log("item",item);
    },
    isShowSecReply(item) {
      console.log("一级input获取焦点",item);
      let id = item?.id;
      if (id) {
        this.isShowSec = id;
        if (this.isClickId === this.isShowSec) {
          this.isShowSec = "";
        } else {
          this.isShowSec = id;
        }
        this.isClickId = this.isShowSec;
      } else {
        this.isShowSec = this.isClickId = "";
      }
    },
    // 删除
    deleteComment(item, reply) {
      if(!this.isLogin) {
        this.$message.warning("请先登录!");
        return;
      }
      let _id = item.id;
      let replyId = reply?.id;
      if (replyId) {
        // 删除二级评论,提交请求到后端
        this.$confirm('确认删除此评论?', '提示', {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          this.$put(`/api/comment/${replyId}`).then(async (res) => {
            if (res.code === 200) {
              this.params.pageNum = '1';
              let data = await this.getNewList();
              console.log("data",data);
              item.commentReplyCount = data.total;
              item.commentReplyInfoList.splice(0);
              item.commentReplyInfoList.push(...data.rows);
              //
              this.$message({
                message: "删除成功",
                type: "success",
              });
            }
          });
        }).catch(() => {
          this.$message({
            type: 'info',
            message: '已取消删除'
          });
        })
      } else {
        // 删除一级评论,提交请求到后端
        this.$confirm('确认删除此评论?', '提示', {
          confirmButtonText: '确定',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          this.$put(`/api/comment/${_id}`).then((res) => {
            if (res.code === 200) {
              this.$message({
                message: "删除成功",
                type: "success",
              });
              this.params.pageNum = '1';
              this.getCommentList();
            }
          });
          this.total--;
          if(this.total == this.params.pageSize) {
            this.morelist.value = false;
          }
        }).catch(() => {
          this.$message({
            type: 'info',
            message: '已取消删除'
          });
        })
      }
    },
    // 评论添加成功,返回的数据
    async addComment(item,reply) {
      console.log(">>>>item",item );
      if(!this.isLogin) {
        this.$message.warning("请先登录!");
        return;
      }
      //本地更新评论列表
      if (item) {
        // 添加二级评论
        if (!this.replyContext) {
          this.$message.warning("评论或留言不能为空哦!");
          return;
        }
        this.addSecond(item,reply);
      } else {
        // 添加一级评论,提交数据到后端
        if (!this.context) {
          this.$message.warning("评论或留言不能为空哦!");
          return;
        }
        this.addFirst();
      }
      this.isShowSec = this.isClickId = "";
    },
    addSecond(item,reply) {
      // 模拟数据提交成功后返回数据
      let data = {
        mainId: item.id,
        parentId: item.id,
        sourceType: item.sourceType,
        sourceId: this.$route.query.id, 
        commentContent: this.replyContext //评论内容
      };
      if(reply) {
        data.parentId = reply.id;
      }
      this.$post("/api/comment",data).then(async(res) => {
        if(res.code == 200) {
          this.params.mainId = item.id;
          let res = await this.getNewList();
          if(!item.commentReplyInfoList) {
            item.commentReplyInfoList = [];
          }
          item.commentReplyInfoList.splice(0);
          item.commentReplyInfoList.push(...res.rows);
          item.commentReplyCount = res.total;
          if(res.total > 5) {
            item.moreValue = true;
          }
          this.replyContext = "";
          console.log("comments",this.comments);
        }
      })
    },
    addFirst() {
      // 模拟数据提交成功后返回数据
      let data = {
        mainId: '',
        parentId: '',
        sourceType: this.sourceType,
        sourceId: this.$route.query.id, 
        commentContent: this.context //评论内容
      };
      this.$post("/api/comment",data).then(async(res) => {
        if(res.code == 200) {
          this.params.mainId = '';
          let res = await this.getNewList();
          res.rows.map(item => {
            this.$set(item,'publicTotal',false)
          })
          this.comments = res.rows;
          this.context = "";
          this.total = res.total;
          if(res.total > 5) {
            this.morelist.value = true;
          }
        }
      })
    },
  }
};
</script>
<style lang="scss" scoped>
.comment {
  min-height: 26vh;
  border-radius: 5px;
  margin-top: 2px;
  overflow: hidden;
  h3 {
    margin: 5px 0;
  }
  p {
    margin: 3px 0;
  }
  ul {
    list-style-type: none;
  }
  .img_width {
    max-width: 50px;
    max-height: 50px;
    border-radius: 50px;
    font-size: 30px;
    color: #3339;
  }
  .flex_box {
    padding: 0 5px;
    display: flex;
    align-items: center;
    margin-bottom: 30px;
    h2 {
      margin: 0;
    }
    p {
      font-size: 16px;
      color: #666;
      margin: 0 0 0 20px;
    }
  }
  .active {
    color: rgb(202, 4, 4);
  }
  .comment-header {
    position: relative;
    // height: 50px;
    padding: 10px 5px;
    // display: flex;
    // align-items: center;
    .header_children {
      display: flex;
      align-items: center;
    }
    .header_btn {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 20px 0 60px;
      margin-top: 10px;
    }

    .input {
      margin-left: 10px;
      margin-right: 20px;
      flex: 1;
      font-size: 14px;
      ::v-deep .el-input__inner:focus {
        border-color: #dcdfe6;
      }
    }
  }

  .comment-body {
    font-size: 14px;
    .first-comment {
      display: flex;
      padding: 10px 20px;
      .input {
        ::v-deep.el-input__inner:focus {
          border-color: #dcdfe6;
        }
      }
      i {
        margin-right: 5px;
        margin-left: 1vw;
        cursor: pointer;

        // &:nth-child(3) {
        //   color: rgb(202, 4, 4);
        // }
      }

      .content {
        margin-left: 10px;
        position: relative;
        flex: 1;

        & > span {
          font-size: 12px;
          color: rgb(130, 129, 129);
        }

        .comment-right {
          position: absolute;
          right: 0;
          top: 0;
        }

        .reply-comment {
          // height: 60px;
          // display: flex;
          // align-items: center;
          .flex_one_box {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-top: 10px;
          }

          .reply-button {
            margin-left: 20px;
            height: 35px;
          }
        }
        .li_top {
          width: 200px;
          cursor: pointer;
          padding-left: 50px;
        }

        .infinite_list {
          max-height: 333px;
        }

        .second-comment {
          display: flex;
          padding: 10px 0 10px 5px;
          border-radius: 20px;
          background: #ffffff;
          .to_reply {
            // color: rgb(126, 127, 128);
            color: #0d74e1;
          }
        }
        .loading_p {
          cursor: pointer;
          margin: 3px auto;
          display: flex;
          justify-content: center;
        }
      }
    }
  }

  .moreBtn {
    width: 100%;
    i {
      font-size: 16px;
    }
  }
  .more_div {
    display: flex;
    display: flex;
    justify-content: center;
    color: #66666694;
    font-size: 14px;
  }
  ::-webkit-scrollbar
  {
    width: 8px;
    height: 5px;
    background-color: #F5F5F5;
  }
  /*定义滚动条轨道 内阴影+圆角*/
  ::-webkit-scrollbar-track
  {
    -webkit-box-shadow: inset 0 0 6px #c0c0c0;
    border-radius: 10px;
    background-color: #f9f9f9;
  }
  /*定义滑块 内阴影+圆角*/
  ::-webkit-scrollbar-thumb
  {
    border-radius: 10px;
    -webkit-box-shadow: inset 0 0 6px #c0c0c0;
    background-color: #c0c0c0;
  }
}
</style>

表情组件如下:

1. 创建 VueEmoji.vue 文件。

<template>
    <div class="emoji">
        <ul class="emoji-default" v-show="isShowEmoji">
            <li v-for="(item, index) in emojiJson" :key="index"
                @click.stop="chooseEmojiDefault(item)">
                {{item}}
            </li>
        </ul>
        <div class="emoji-tabs">
            <div :class="!isShowEmoji ? 'emoji-tab' : 'new_tab'" @click="isShowEmoji = !isShowEmoji">
                {{ isShowEmoji ? '收起' : emojiJson[4]}}
            </div>
        </div>
    </div>
</template>

<script>

const emojiJson = require("../utils/emoji.json");

export default {
  props: {
    keyId: {
      type: String,
      required: false
    },
    layer: {
      type: Array || Object,
      required: false
    }
  },
  data() {
    return {
      emojiJson: emojiJson.data.split(','),
      isShowEmoji: false,
    }
  },
  methods: {
    chooseEmojiDefault(item) {
      console.log("item",item);
      this.$emit("chooseEmoji",this.keyId,this.layer,item);
      this.isShowEmoji = false;
    }
  }
}

</script>

<style lang="scss" scoped>
// 纵向滚动条
@mixin scroll-bar($width: 10px) {
    &::-webkit-scrollbar-track {
        border-radius: 10px;
        background-color: #ffffff;
    }
    &::-webkit-scrollbar {
        width: $width;
        height: 10px;
        background-color: #ffffff;
    }
    &::-webkit-scrollbar-thumb {
        border-radius: 10px;
        background-color: rgba(0, 0, 0, 0.2);
    }
}

.emoji {
    text-align: left;
    // width: 100%;
    // height: 100%;
    background: #fff;
    // border: 1px solid #dcdfe6;
    box-shadow: 0 2px 4px 0 rgb(0 0 0 / 12%), 0 0 6px 0 rgb(0 0 0 / 4%);

    .emoji-tabs {
        // border-top: 1px solid #DCDFE6;
        background: #f8f8f8;

        .emoji-tab {
          cursor: pointer;
          background: #ffffff;
          /* height: 100%; */
          /* padding: 5px 5px; */
          /* overflow: hidden; */
          text-align: center;
          /* line-height: 15px; */
          font-size: 25px
        }
        .new_tab {
          cursor: pointer;
          font-size: 14px;
          color: #1c33e5;
          text-align: center;
          padding: 5px 0;
        }
    }


    .emoji-default {
        width: calc(100% - 40px);
        height: 202px;
        overflow-y: auto;
        @include scroll-bar();
        padding: 0px 20px;

        li {
            display: inline-block;
            padding: 5px;
            font-size: 24px;
            width: 29px;
            height: 29px;
            overflow: hidden;
            cursor: pointer;
        }

        li:hover {
            background-color: #d5d5d5;
        }
    }
}

</style>

2. 在写公共方法的地方创建 emoji.json 文件,里面是表情的数据,内容如下:

{
  "data": "😀,😁,😂,😃,😄,😅,😆,😉,😊,😋,😎,😍,😘,😗,😙,😚,😇,😐,😑,😶,😏,😣,😥,😮,😯,😪,😫,😴,😌,😛,😜,😝,😒,😓,😔,😕,😲,😷,😖,😞,😟,😤,😢,😭,😦,😧,😨,😬,😰,😱,😳,😵,😡,😠,💘,❤,💓,💔,💕,💖,💗,💙,💚,💛,💜,💝,💞,💟,❣,💪,👈,👉,☝,👆,👇,✌,✋,👌,👍,👎,✊,👊,👋,👏,👐,✍,🍇,🍈,🍉,🍊,🍋,🍌,🍍,🍎,🍏,🍐,🍑,🍒,🍓,🍅,🍆,🌽,🍄,🌰,🍞,🍖,🍗,🍔,🍟,🍕,🍳,🍲,🍱,🍘,🍙,🍚,🍛,🍜,🍝,🍠,🍢,🍣,🍤,🍥,🍡,🍦,🍧,🍨,🍩,🍪,🎂,🍰,🍫,🍬,🍭,🍮,🍯,🍼,☕,🍵,🍶,🍷,🍸,🍹,🍺,🍻,🍴,🌹,🍀,🍎,💰,📱,🌙,🍁,🍂,🍃,🌷,💎,🔪,🔫,🏀,⚽,⚡,👄,👍,🔥,🙈,🙉,🙊,🐵,🐒,🐶,🐕,🐩,🐺,🐱,😺,😸,😹,😻,😼,😽,🙀,😿,😾,🐈,🐯,🐅,🐆,🐴,🐎,🐮,🐂,🐃,🐄,🐷,🐖,🐗,🐽,🐏,🐑,🐐,🐪,🐫,🐘,🐭,🐁,🐀,🐹,🐰,🐇,🐻,🐨,🐼,🐾,🐔,🐓,🐣,🐤,🐥,🐦,🐧,🐸,🐊,🐢,🐍,🐲,🐉,🐳,🐋,🐬,🐟,🐠,🐡,🐙,🐚,🐌,🐛,🐜,🐝,🐞,🦋,😈,👿,👹,👺,💀,☠,👻,👽,👾,💣"
}

在 Vue 文件中使用,如下:

<template>
    <div>
       <comment :buttonText="'发表评论'"/>
    </div>
</template>

效果如下:

GitHub 加速计划 / eleme / element
54.06 K
14.63 K
下载
A Vue.js 2.0 UI Toolkit for Web
最近提交(Master分支:1 个月前 )
c345bb45 5 个月前
a07f3a59 * Update transition.md * Update table.md * Update transition.md * Update table.md * Update transition.md * Update table.md * Update table.md * Update transition.md * Update popover.md 5 个月前
Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐