CKEditor接入阿里云oss上传图片

csdn本文链接:https://blog.csdn.net/u010004317/article/details/89513099

一. 前言

目前公司使用的富文本编辑器是百度的UEditor,目前其最新的版本停留在了16年的版本,基本算是无人维护的了,作为一个传统但是强劲的富文本编辑器,UEditor在初期的确是很多学生或者小型创业公司后台开发的首选富文本编辑器,但是随着业务的深入,UEditor的问题也暴露出来了。

  • 代码结构复杂,无法很好地自定义和扩展(这个是很重要的原因,改起来真的很麻烦)

  • bug多,不够稳定

  • 样式太过老旧

富文本编辑器对于我们来说,要简单,够用就好了,太多花里胡哨的功能我们都不需要用到,参考过公众号和知乎的富文本编辑器,都是很简单的,虽然对对于我们做电商来说太过简单了,但是基本功能和样式就是我们想要的,我们完全可以在其基础上进行扩展。

另外个原因,我们的图片服务器是oss,所以需要通过富文本编辑器直传图片到oss中,直接在百度UEditor中,花了很大的力气接入了oss,虽然功能基本实现了,但是效果很不理想,无法所见即所得。

基于以上种种,我在市面上找了好几个编辑器,但是效果都不是很理想,接下来我列一下。

1. Editor.js

  • 官网:https://editorjs.io/

  • 简介:该编辑器是以块作为基本元素来的,每次编辑都是在一个块上进行编辑,主要的特点是,它的输出是json格式的,而不是传统的html格式,这是我非常喜欢的。可自定义插件,操作简单快捷。

  • 建议:该编辑器适合业务不复杂的场景,以及只有pc端展示的场景,如写简单文章之类的,如果对于公司来说,需要从word编辑,需要提供移动端做html展示(对于ios和安卓来说,他们只能通过html来做渲染,无法引入该编辑器),该编辑器则无法实现

在这里插入图片描述

2. NKeditor

  • 官网:https://gitee.com/blackfox/kindeditor

  • 简介:基于 kindeditor 进行二次开发的项目,文档少,理解困难,这是产品找给我的

  • 建议:不推荐用,没文档的开源就是坑,而且还是二次开发的。

在这里插入图片描述

3. wangEditor

  • 官网:http://www.wangeditor.com/

  • 简介:这款是我在网上发现很多人推崇的,github的star数也有7000多,算是比较活跃的一款开源编辑器,文档算是比较齐全,我刚开始也是觉得挺OK的,但是另外一个方面我觉得他的自定义插件功能不够强大,我的项目对自定义插件的要求很高,所以放弃

  • 建议:对自定义插件功能要求不高的,我推荐这款编辑器

在这里插入图片描述

4. CKEditor

https://ckeditor.com/ (CKEditor地址)

  • 简介:CKEditor分CKEditor4和CKEditor5,因为CKEditor5要用到npm打包,并且其缺少了一些我觉得很重要的功能,所以我选择了CKEditor4,而且CKEditor5比较新,CKEditor4经历过了一段时间的考验。这也是在机缘巧合之下发现了这款编辑器,是国外开源的一款编辑器,拥有自己的详细的官网,而且官网做得还不错,github的社区也十分活跃,最近一次提交也是在一个月内,让我看到了他的可靠性,至少在未来两年内,该框架都会持续更新和优化,从长远来看,这都是其他编辑其所无法实现的。

  • 他提供了多种编辑器样式供选择,而且文档很详细,老外写的文档,不得不说,真的很详细,虽然很长,像nginx,spring-boot的文档一样。而且文档中有各种demo,并且demo也提供了源码,给初学者提供了一个很好的入门机会。

  • 建议:强烈推荐,只是是英文文档,读起来有点费力,不过技术英文文档估计是最简单的英文入门了,所用词汇相对简单,再结合词典就很容易理解了,不推荐把整个文档翻译成中文来看,先读英文文档,发现不懂的单词再去查词典,结合上下文理解他的语境。

在这里插入图片描述
在这里插入图片描述

二. 搭建基础的CKEditor框架

1. 基本配置

官网下载文件,下载界面可选:Basic Package、Standard Package、Full Package、Customize。根据自己需求下载不同的安装包,而且每种都有压缩版和源码版可选。其中Customize版本顾名思义可自定义选择自己需要的模块,官方也推荐使用这种方式自定义下载。Customize版本相当于在线上让你通过图形化界面自定义自己想要的插件和皮肤样式等,说实在,这个功能很强大。

1. 1 引入js

<script src="../install package/ckeditor/ckeditor.js"></script>

1.2 写一个textarea标签

  <textarea name="editor1" id="editor1" rows="10" cols="80">

        This is my textarea to be replaced with CKEditor.

    </textarea>

1.3 初始化CKEditor

CKEDITOR.replace('editor1', {

    uiColor: '#9AB8F3'

});

1.4 完整代码

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <script src="../../install package/ckeditor/ckeditor.js"></script>

    <link href="simple-image.css" rel="stylesheet" />

</head>

<form action="" method="post" onclick="save()">

    <textarea name="editor1" id="editor1" rows="10" cols="80">

        This is my textarea to be replaced with CKEditor.

    </textarea>



</form>

<script>



        window.onload = function() {



        }

        // Replace the <textarea id="editor1"> with a CKEditor

        // instance, using default configuration.

        CKEDITOR.replace('editor1', {

            uiColor: '#9AB8F3'

        });

        function save() {

            var data = CKEDITOR.instances.editor1.getData();

            alert(data)

            console.log(data);

        }

    </script>

<body>

</body>

</html>

1.5 效果
在这里插入图片描述

2. 自定义配置

CKEditor做得十分人性化,它不需要你去读文档,知道每个插件图标的的配置信息,然后去代码中手动添加或移除,他在full的文档中提供了一个html页面,可以通过图形化界面自定义配置信息,该html的页面在ckeditor⁩/sample/index.html

在这里插入图片描述
在这里插入图片描述

生成的配置文件

在这里插入图片描述

代码配置


CKEDITOR.replace('editor1', {

          toolbarGroups: [

                { name: 'document', groups: [ 'mode', 'document', 'doctools' ] },

                { name: 'clipboard', groups: [ 'clipboard', 'undo' ] },

                { name: 'editing', groups: [ 'find', 'selection', 'spellchecker', 'editing' ] },

                { name: 'forms', groups: [ 'forms' ] },

                '/',

                { name: 'basicstyles', groups: [ 'basicstyles', 'cleanup' ] },

                { name: 'paragraph', groups: [ 'list', 'indent', 'blocks', 'align', 'bidi', 'paragraph' ] },

                { name: 'links', groups: [ 'links' ] },

                { name: 'insert', groups: [ 'insert' ] },

                '/',

                { name: 'styles', groups: [ 'styles' ] },

                { name: 'colors', groups: [ 'colors' ] },

                { name: 'tools', groups: [ 'tools' ] },

                { name: 'others', groups: [ 'others' ] },

                { name: 'about', groups: [ 'about' ] }

            ],

            removeButtons:  'Source,Save,Templates,Undo,Find,SelectAll,Scayt,Form,Bold,CopyFormatting,NumberedList,Outdent,Blockquote,JustifyLeft,BidiLtr,Link,Image,Cut,Copy,Redo,Replace,NewPage,Preview,Print,Paste,PasteText,PasteFromWord,Checkbox,Radio,TextField,Textarea,Select,Button,ImageButton,HiddenField',

            allowedContent: false  //关闭acf功能,不建议关闭

      });

三. CKEditor自定义插件

CKEditor4提供了强大的自定义插件功能,强烈建议根据它的官方文档中的4个demo一个个手敲一遍,而且一个都不要落下,敲完之后你会对CKEditor4的自定义插件有个更加深入的认识。因为公司用到的图片服务器是阿里云的oss,所以需要在编辑器中接入oss,针对图片都上传到oss中,我这边就简单介绍下如何自定义oss上传插件

1. 文件结构

CKEditor4规定自定义插件,必须要在plugins目录下新建一个插件同名文件夹,然后在该文件夹下有plugin.js(用于写插件的初始化等基本信息),dailog文件夹,该文件夹下的js用于实现对话框的具体业务逻辑,icons文件夹,该文件夹下有xxx.png的图片,作为插件的图标。


uploadimages

......dialog

.............uploadimage.js

.............upload.js

......icons

.............uploadimages.png

......plugin.js

2. 代码

2.1 plugin.js

CKEDITOR.plugins.add('uploadimages', {

icons: 'uploadimages',

//初始化方法

init: function(editor) {

//添加命令,最简单的自定义插件,可以在addCommand这个方法里面,通过定义exec,直接插入相应的html即可

/***** 

演示最简答的自定义插件,插入相应的html,与本例子无关

editor.addCommand('insertTimestamp', {

exec: function(editor) {

var now = new Date();

editor.insertHtml('<p>The current date and time is232323232: ' + now.toString() + 'xcxzc</p><br/>');

}

});

****/

editor.addCommand('uploadimages', new CKEDITOR.dialogCommand('uploadimagesDialog', {

// allowedContent: 'abbr[title,id]',

// requiredContent: 'abbr'

}));

//添加插件按钮

editor.ui.addButton('UploadImages', {

label: '上传图片',

command: 'uploadimages',

toolbar: 'insert'

});

//添加对话框,配置实现对话框逻辑的js文件

CKEDITOR.dialog.add('uploadimagesDialog', this.path + 'dialog/uploadimages.js');

}

});

2.2. upload.js

我们使用的oss上传是通过官方提供的plupload.Uploader与后台进行交互获取秘钥等信息进行上传的,在CKEditor,官方提供的例子要做些修改

由下面的代码可以看出,官方提供的例子的plupload.Uploader的初始化方法被我移走了,是的,初始化事件要在CKEditor中完成,


accessid = ''

accesskey = ''

host = ''

policyBase64 = ''

signature = ''

callbackbody = ''

filename = ''

key = ''

expire = 0

g_object_name = ''

g_object_name_type = 'local_name'

now = timestamp = Date.parse(new Date()) / 1000;

function send_request()

{

    var xmlhttp = null;

    if (window.XMLHttpRequest)

    {

        xmlhttp=new XMLHttpRequest();

    }

    else if (window.ActiveXObject)

    {

        xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");

    }



    if (xmlhttp!=null)

    {

        // serverUrl是 用户获取 '签名和Policy' 等信息的应用服务器的URL,请将下面的IP和Port配置为您自己的真实信息。

        serverUrl = 'http://xxxxxxxx/api/oss/getOssServer'

        xmlhttp.open( "GET", serverUrl, false );

        xmlhttp.send( null );

        return xmlhttp.responseText

    }

    else

    {

        alert("Your browser does not support XMLHTTP.");

    }

};

function check_object_radio() {

    var tt = document.getElementsByName('myradio');

    for (var i = 0; i < tt.length ; i++ )

    {

        if(tt[i].checked)

        {

            g_object_name_type = tt[i].value;

            break;

        }

    }

}

function get_signature()

{

    // 可以判断当前expire是否超过了当前时间, 如果超过了当前时间, 就重新取一下,3s 作为缓冲。

    now = timestamp = Date.parse(new Date()) / 1000;

    if (expire < now + 3)

    {

        body = send_request()

        var obj = eval ("(" + body + ")");

        host = obj['host']

        policyBase64 = obj['policy']

        accessid = obj['accessid']

        signature = obj['signature']

        expire = parseInt(obj['expire'])

        callbackbody = obj['callback']

        key = obj['dir']

        return true;

    }

    return false;

};

function random_string(len) {

  len = len || 32;

  var chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678'; 

  var maxPos = chars.length;

  var pwd = '';

  for (i = 0; i < len; i++) {

      pwd += chars.charAt(Math.floor(Math.random() * maxPos));

    }

    return pwd;

}

function get_suffix(filename) {

    pos = filename.lastIndexOf('.')

    suffix = ''

    if (pos != -1) {

        suffix = filename.substring(pos)

    }

    return suffix;

}

function calculate_object_name(filename)

{

    if (g_object_name_type == 'local_name')

    {

        g_object_name += "${filename}"

    }

    else if (g_object_name_type == 'random_name')

    {

        suffix = get_suffix(filename)

        g_object_name = key + random_string(10) + suffix

    }

    return ''

}

function get_uploaded_object_name(filename)

{

    if (g_object_name_type == 'local_name')

    {

        tmp_name = g_object_name

        tmp_name = tmp_name.replace("${filename}", filename);

        return tmp_name

    }

    else if(g_object_name_type == 'random_name')

    {

        return g_object_name

    }

}

function set_upload_param(up, filename, ret)

{

    if (ret == false)

    {

        ret = get_signature()

    }

    g_object_name = key;

    if (filename != '') { suffix = get_suffix(filename)

        calculate_object_name(filename)

    }

    new_multipart_params = {

        'key' : g_object_name,

        'policy': policyBase64,

        'OSSAccessKeyId': accessid,

        'success_action_status' : '200', //让服务端返回200,不然,默认会返回204

        'callback' : callbackbody,

        'signature': signature,

    };

    up.setOption({

        'url': host,

        'multipart_params': new_multipart_params

    });

    up.start();

}

2.3 uploadimages.js

var srcArray = new Array();

var index = 0;

CKEDITOR.dialog.add('uploadimagesDialog', function (editor) {

var testHtml = new CKEDITOR.template('<div></dvi>').output();

return {

title: '图片上传',

minWith: 400,

minHeight: 200,

//content是子标签

contents: [

{

id: 'ossimage',

label: '上传图片',

//elements是每个子标签下面的ui元素,如表单元素等

elements: [    //自定义弹窗的内容,可以使用模板,也可自定义html及样式

{

type: 'html',    //图片上传成功后的容器

html: '<div id="ossfile"</div>',

style: '',  //对应的样式

onShow: function () {

//在每次弹窗打开的时候都会调用该方法

},

//点击确定按钮时,在onOK中调用commitContent,会依次触发element的commit方法

commit: function (editor) {  //点击确定按钮时,将图片src传入全局src中

src = $('.imgbox img').attr('src');

},

//点击确定按钮时,在onOK中滴啊用superContent,会依次触发element的setup方法

setup: function (editor) {

}

},

{

type: 'html',    //图片上传成功后的容器

html: '<a id="postfiles" href="javascript:void(0);" class="btn">开始上传</a>',

style: 'display:none;',  //对应的样式

},

{

id: 'myimage',    //选择图片按钮

type: 'html',

html: '<div id="container"><a id="selectfiles" href="javascript:void(0);" class="btn">选择文件</a></div>',  //plupload按钮

style: 'display:block;width:82px;line-height:34px;background-color:#3366b7;font-size:14px;color:#fff;text-align:center;border-radius:4px;',  //html的样式,直接作用于上面的a元素

onShow: function () {  //当该元素show的时候执行的方法

document.getElementById('ossfile').innerHTML = '';

},

onLoad: function () {

//uploader需要再onLoad方法中定义,因为只有在onload的时候,才能获取到‘selectfiles’的html元素,uploader才能初始化

var uploader = new plupload.Uploader({

runtimes: 'html5,flash,silverlight,html4',

browse_button: 'selectfiles',

//multi_selection: false,

container: document.getElementById('container'),

flash_swf_url: 'lib/plupload-2.1.2/js/Moxie.swf',

silverlight_xap_url: 'lib/plupload-2.1.2/js/Moxie.xap',

url: 'http://oss.aliyuncs.com',

filters: {

mime_types: [ //只允许上传图片和zip文件

{ title: "Image files", extensions: "jpg,gif,png,bmp" },

{ title: "Zip files", extensions: "zip,rar" }

],

max_file_size: '10mb', //最大只能上传10mb的文件

prevent_duplicates: true //不允许选取重复文件

},

init: {

PostInit: function () {

document.getElementById('ossfile').innerHTML = '';

document.getElementById('postfiles').onclick = function() {

set_upload_param(uploader, '', false);

return false;

};

},

FilesAdded: function (up, files) {

plupload.each(files, function (file) {

document.getElementById('ossfile').innerHTML +=

'<div style="width:150px;height:150px;border:solid 1px;text-align: center;float:left" id="' + file.id + '">'

+ '<image src="" alt=“占位图”/>'

+ '</div>';

});

document.getElementById('postfiles').click();

},

BeforeUpload: function (up, file) {

check_object_radio();

set_upload_param(up, file.name, true);

},

//上传中,这里根据需要自己写上传等待,也可在外部实现

UploadProgress: function (up, file) {

// var d = document.getElementById(file.id);

// d.getElementsByTagName('b')[0].innerHTML = '<span>' + file.percent + "%</span>";

// var prog = d.getElementsByTagName('div')[0];

// var progBar = prog.getElementsByTagName('div')[0]

// progBar.style.width = 2 * file.percent + 'px';

// progBar.setAttribute('aria-valuenow', file.percent);

},

FileUploaded: function (up, file, info) {

if (info.status == 200) {

var imageSrc = get_uploaded_object_name(file.name);

//记住:这里要根据imageSrc最后的图片名字进行从小到大排序,即0.png,1.png这样子依次插入到数组中人,然后再根据输入插入到富文本编辑器中

//因为oss上传针对同个文件名不能上传多次,所以在upload.js要针对文件名进行有规律地自定义

//上传成功之后显示图片缩略图

srcArray[index] = imageSrc;

index = index + 1;

console.log(document.getElementById(file.id))

document.getElementById(file.id).innerHTML = '<image src="https://XXXX/'+ imageSrc +'?x-oss-process=image/resize,w_150,h_150" alt=“”/>';

}

else if (info.status == 203) {

document.getElementById(file.id).getElementsByTagName('b')[0].innerHTML = '上传到OSS成功,但是oss访问用户设置的上传回调服务器失败,失败原因是:' + info.response;

}

else {

document.getElementById(file.id).getElementsByTagName('b')[0].innerHTML = info.response;

}

},

Error: function (up, err) {

if (err.code == -600) {

console.log("\n选择的文件太大了,可以根据应用情况,在upload.js 设置一下上传的最大大小")

// document.getElementById('console').appendChild(document.createTextNode("\n选择的文件太大了,可以根据应用情况,在upload.js 设置一下上传的最大大小"));

}

else if (err.code == -601) {

console.log("\n选择的文件后缀不对,可以根据应用情况,在upload.js进行设置可允许的上传文件类型")

// document.getElementById('console').appendChild(document.createTextNode("\n选择的文件后缀不对,可以根据应用情况,在upload.js进行设置可允许的上传文件类型"));

}

else if (err.code == -602) {

console.log("\n这个文件已经上传过一遍了");

// document.getElementById('console').appendChild(document.createTextNode("\n这个文件已经上传过一遍了"));

}

else {

console.log("\nError xml:" + err.response);

// document.getElementById('console').appendChild(document.createTextNode("\nError xml:" + err.response));

}

}

}

});

uploader.init();

}

},

{

//这里只是演示可以通过在外部写html,然后进行展示

id: 'size', 

type: 'html',

html: testHtml,  //html写到了上面

commit: function (editor) {

var tt = document.getElementsByName('size'); //取radio选项

for (var i = 0; i < tt.length; i++) {

if (tt[i].checked) {

imgsize = tt[i].value;

break;

}

}

}

}

]

}

],

onShow: function () {

},

onOk: function () {

//该方法会依次调用element数组中的commit方法,在这里我们不需要在element中做额外调用,所以不使用,如果需要的话可以开启使用

//this.commitContent(editor);

//点击确定时,把图片依次插入

console.log(srcArray);

for (x in srcArray) {

var realImageSrc = "https://XXXXXXX/" + srcArray[x];

var ele = CKEDITOR.dom.element.createFromHtml('<p style="padding:5px 0;"><img style="width:250px;height:250px" src="' + realImageSrc + '"/></p><br/>');

editor.insertElement(ele);  //将element插入editor

}

},

onCancel: function () {

}

}

});

2.4 html
在这里插入图片描述

3. 效果图

本来为大家录制了一段效果图,但是gif太大,上传不了,故作罢

在这里插入图片描述

四. CKEditor的ACF功能

CKEditor4有个叫做ACF的功能,能过滤掉一些标签,如script等,在前端就已经组织了大部分的我们觉得不需要的标签,提高了整个编辑器的安全性,但是CKEdor4不建议只用这个功能来做为安全性的校验,后台也应该做相应的校验。但是也是因为这个功能的存在,导致你在自定义插件的时候,可能会遇到一些莫名其妙的问题,如标签失效等,这个时候可以先把这个功能个关掉,等插件完成后再根据自己的需要打开。

官方对于ACF的解释:https://ckeditor.com/docs/ckeditor4/latest/guide/dev_acf.html


//disallowedContent: 'img{width,height,float}',

//extraAllowedContent: 'img[width,height,align]',

allowedContent: 'p abbr[title,id]',

五. 参考文档

https://liyang0207.github.io/2017/08/22/ckeditor%E8%87%AA%E5%AE%9A%E4%B9%89%E6%8C%89%E9%92%AE%E5%8F%8A%E9%98%BF%E9%87%8Coss%E4%B8%8A%E4%BC%A0/

https://ckeditor.com/docs/ckeditor4/latest/index.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,922评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,591评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,546评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,467评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,553评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,580评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,588评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,334评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,780评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,092评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,270评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,925评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,573评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,194评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,437评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,154评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352

推荐阅读更多精彩内容